JWT Token을 이용한 로그인 기능
JWT Token을 선택한 이유
- 서버 간 세션을 공유해야 하는 세션 로그인보다 서버 부하 측면에서 이점을 챙길 수 있는 JWT Token이 사용하기에 더 적합하다고 생각하여 사용
- JSON 통신을 통해 JavaScript를 이용하여 클라이언트-서버 간의 상호작용을 원활하게 하기 위해 JWT Token 로그인을 사용
- 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의 모든 정보 삭제)를 하도록 함