JS

JWT Token을 이용한 로그인 기능

devjingood 2025. 3. 12. 00:53

JWT Token을 선택한 이유

  1. 서버 간 세션을 공유해야 하는 세션 로그인보다 서버 부하 측면에서 이점을 챙길 수 있는 JWT Token이 사용하기에 더 적합하다고 생각하여 사용
  2. JSON 통신을 통해 JavaScript를 이용하여 클라이언트-서버 간의 상호작용을 원활하게 하기 위해 JWT Token 로그인을 사용
  3. JWT는 서명된 토큰을 사용하여 토큰의 무결성을 검증할 수 있으므로, 짧은 만료 시간과 리프레시 토큰을 사용하여 보안을 강화할 수 있다고 생각하여 사용

JWT Token 기본 로직 설명

// base.js
function parseJwt(token) {
    try {
        return JSON.parse(atob(token.split('.')[1])); 
    } catch (e) {
        return null;
    }
}

JWT 토큰을 디코딩하는 함수,
Access Token은 Header, Payload, Signature 부분으로 "."을 통해 3등분 되는데,
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.(Header)
eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzE3NTEyNzA3LCJpYXQiOjE3MTc1MTA5MDcsImp0aSI6Ijg5OTUxNjJhZjY2NzRhOTg4Y2MxYjE3MjMyNzUxMjViIiwidXNlcl9pZCI6MSwidXNlcl9uaWNrbmFtZSI6IkpJTiJ9.(Payload)
-eJKR2QLJAzFW2zm9g8VLeaDjZLP1FdGwYv_amTi7e4(Signature)
Token 정보를 표현하는 데 사용하는 Payload 부분을 추출하기 위해,
Access Token을 디코딩하는 함수

// base.js
async function refreshAccessToken() { 
    const refreshToken = localStorage.getItem('refresh');
    try {
        const response = await axios.post('/api/accounts/api/token/refresh/', { refresh: refreshToken }); 
        localStorage.setItem('access', newAccessToken);
        return newAccessToken;
    } catch (error) {
        console.error('Error refreshing token:', error);
        localStorage.removeItem('access');
        localStorage.removeItem('refresh');
        localStorage.removeItem('user_id');
        localStorage.removeItem('user_nickname');
        if (window.location.pathname !== '/api/accounts/login/') {
            window.location.href = '/api/accounts/login/';
        }
        throw error; 
    }
}

refreshAccessToken() : Refresh Token을 이용하여 Access Token을 재발급 받는 로직

const refreshToken = localStorage.getItem('refresh'); : 로그인 시 localStorage에 저장한 Refresh Token을 refreshToken이라는 상수로 선언

try { const response = await axios.post('/api/accounts/api/token/refresh/', {refresh: refreshToken }); localStorage.setItem('access', newAccessToken); return newAccessToken;

# accounts/urls.py
path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),

urls.py에서 지정해 준 TokenRefreshView로 연결하는 url에 axios.post 요청을 보낼 때,
Refresh Token을 담아서 보내 준 뒤, response로 받아온 Access Token을
newAccessToken으로 return해준다.

} catch (error) { console.error('Error refreshing token:', error);: error, 즉, Refresh Token이 만료되거나, 이미 Blacklist에 들어갔을 때 새로운 Access Token을 발급받지 못하고, error가 생기는데, 그 때는 로그인 시 생성했던 localStorage에 있는 모든 정보를 제거

if (window.location.pathname !== '/api/accounts/login/') { window.location.href = '/api/accounts/login/'; } throw error;} }: (이미 로그인 페이지인 것이 아니라면) 로그인 페이지로 이동
throw error는 catch 블록 내부에서 발생한 예외를 다시 던지는 역할,이로 인해 refreshAccessToken 함수를 호출한 상위 코드에서 이 예외를 캐치

// base.js
axios.interceptors.request.use(
    async config => { 

        const excludedUrls = ['/api/accounts/signup/', 
        '/api/accounts/api/token/', 
        '/api/accounts/login/', 
        '/api/accounts/logout/', 
        '/api/accounts/api/signup/', 
        '/api/playlist/', 
        '/api/playlist/data/', 
        '/api/playlist/search/', 
        '/api/playlist/zzim/<int:playlist_id>/',
        '/api/posts/',
        '/api/posts/list/', 
        '/api/posts/api/<int:post_id>', 
        '/api/posts/<int:post_id>'];
        if (excludedUrls.some(url => config.url.includes(url))) { 
            return config;
        }

        let accessToken = localStorage.getItem('access');  
        const tokenData = parseJwt(accessToken);
        const now = Math.ceil(Date.now() / 1000);



        if (tokenData.exp < now) { 
            accessToken = await refreshAccessToken();

        config.headers['Authorization'] = 'Bearer ' + accessToken;  
        return config;
    },
    error => {
        return Promise.reject(error);
    }
);

axios.interceptors.request.use: 모든 HTTP request 전에 호출 되는 interceptor

(async config =>: config는 axios 요청의 설정을 포함하는 객체
(url, headers, method, params, data)

{ const excludedUrls = [ : 특정 url을 제거

if (excludedUrls.some(url => config.url.includes(url))) { return config; }: 요청한 url이 제외 url에 포함되어 있다면 요청 구성을 그대로 반환하여 인터셉터를 종료

let accessToken = localStorage.getItem('access');: 재할당이 가능해야 하므로 변수인 let으로 선언

const tokenData = parseJwt(accessToken);: 처음에 서술한 Access Token 디코딩 함수 호출 -> Payload 부분만 선언

const now = Math.ceil(Date.now() / 1000);: Math.ceil -> JS 내장 함수로 반올림하는 역할, Date.now는 현재 시간을 밀리초로 나타내고, 밀리초를 1000으로 나누어 초 단위로 변경

if (tokenData.exp < now): Payload에 포함되있는 exp를 사용하여 토큰 만료 시간을 확인, 현재 시간과 비교

config.headers['Authorization'] = 'Bearer ' + accessToken; : config headers 부분 Authorization에 Access Token을 추가

# accounts/urls.py
path('login/', views.LoginPageView.as_view(), name="login"),
path('api/token/', views.CustomTokenObtainPairView.as_view(), name="token_obtain"),
path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path('logout/', views.LogoutAPIView.as_view(), name='logout'),

 

# accounts/views.py
class CustomTokenObtainPairView(TokenObtainPairView):
    serializer_class = CustomTokenObtainPairSerializer

class LogoutAPIView(APIView):
    permission_classes = [AllowAny]

    def post(self, request):
        try:
            refresh_token = request.data['refresh']
            token = RefreshToken(refresh_token)
            token.blacklist()

        except Exception as e:
            print(f"Exception: {e}")  # 예외 메시지 출력
        return Response(status=status.HTTP_205_RESET_CONTENT)
 
# accounts/serializers.py
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):

    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)
        token["user_id"] = user.id
        token["user_nickname"] = user.nickname
        return token

    def validate(self, attrs):
        data = super().validate(attrs)
        data["user_id"] = self.user.id
        data["user_nickname"] = self.user.nickname
        return data

CustomTokenObtainPairSerializer(TokenObtainPariSerializer):
Django 내장 JWT TokenObtainPairSerializer를 상속 받아 우리가
필요한 데이터를 추가하기 위해 Serializer를 Custom

def get_token(cls, user): 로그인 시 response로 보내주는 token 데이터에 user 정보를 담는 함수

def validate(self, attrs): 넣은 데이터들의 유효성 검사

// accounts/login.js
document.getElementById('login-form').addEventListener('submit', function(e) {
    e.preventDefault();

    // CSRF 토큰을 가져옵니다.
    const csrfToken = getCsrfToken();

    axios.post('/api/accounts/api/token/', {
        username: document.getElementById('username').value,
        password: document.getElementById('password').value
    }, {
        headers: {
            'X-CSRFToken': csrfToken
        }
    })
    .then(response => {
        localStorage.setItem('access', response.data.access);
        localStorage.setItem('refresh', response.data.refresh);
        localStorage.setItem('user_id', response.data.user_id);
        localStorage.setItem('user_nickname', response.data.user_nickname);
        window.location.href = '/api/accounts/main/'
    })
    .catch(error => {
        console.error('로그인 실패.', error);
    });
});

위에서 Custom한 Serializer data를 보내주는 view에 연결된 url로
axios.post 요청을 보내, response로 받은 data들을 localStorage에
저장

// base.js
function logout() {
    const refreshToken = localStorage.getItem('refresh');
    const csrfToken = getCsrfToken();
    axios.post('/api/accounts/logout/', { refresh: refreshToken }, {
        headers: {
            'X-CSRFToken': csrfToken
        }
    })
    .then(response => {
        localStorage.removeItem('access');
        localStorage.removeItem('refresh');
        localStorage.removeItem('user_id');
        localStorage.removeItem('user_nickname');
        window.location.href = '/api/accounts/login/';
    })
    .catch(error => {
        console.error('로그아웃 실패.', error);
    });
}
# accounts/views.py LogoutAPIView(APIView) def post
refresh_token = request.data['refresh']
            token = RefreshToken(refresh_token)
            token.blacklist()

token = RefreshToken(refresh_token): RefreshToken() -> Refresh Token의 유효성 검사를 하는 내장 함수

token.blacklist(): 유효성 검사를 마친 Refresh Token을 Blacklist에 추가하여, 다시 사용할 수 없도록 함.

로그아웃 함수에 request가 들어오게 되면 반드시 로그아웃이 처리가 되게 하여, 이미 Refresh Token이 Blacklist에 있던, 만료 상태이던지에 영향을 받지 않고 로그아웃 처리(localStorage의 모든 정보 삭제)를 하도록 함