이번 포스팅에서는 세션 인증 방식과 함께 모바일과 웹 인증을 책임지는 대표주자 JWT(Json Web Token)에 대해서 알아보려고 한다.
모바일이나 웹의 사용자 인증에 필요한 정보들을 암호화시킨 토큰으로 세션 인증 방식과 유사하게 사용자는 JWT의
Access Token
을Request 객체
의HTTP Header
에 실어서 서버로 보낸다.
토큰은 크게 3가지로 구성된다.
{"alg": "HS256", "typ": "JWT"}
JWT는 주로 사용자 인증 및 권한 부여를 위해 사용되며, 특히 웹 및 모바일 애플리케이션에서 인증된 사용자를 식별하고 권한을 부여하는 데 유용하다.
JWT는 개인정보나 다름 없다.
보안 대책 없이 JWT를 아무데나 저장한다는 것은 '우리 고객정보 아무나 가져가셈'이나 마찬가지
그렇다고 JWT를 프라이빗 변수에만 저장하는 것은 사용자 경험(UX) 측면에서 좋지 않다.
JavaScript 변수는 웹페이지를 새로고침하면 사라지는 휘발성이기 때문에, 사용자가 화면을 새로 고침 할 때마다 JWT를 다시 획득해야한다. 그 말은 즉, 새로 고침을 할 때마다 로그인을 다시 해줘야하는 것과 같다. 생각만해도 에바임;;;;;;;
JWT를 효과적으로 관리하려면 브라우저 쿠키(Cookie)나 로컬 스토리지(Local Storage)와 같은 클라이언트 측 저장소에 안전하게 저장하는 것이 좋다.
일반적으로 토큰은 클라이언트 측에 사용 용도와 보안 요구 사항에 따라 로컬 스토리지에 저장되거나, 쿠키에 저장되기도 한다.
HttpOnly 쿠키 속성을 사용하면 HTTPS 연결을 통해서만 전송되고 중요한 정보를 Javascript로부터 숨길 수 있기 때문에 쿠키를 사용하는게 좋고,
더 큰 데이터를 저장해야 하거나 JavaScript로 쉽게 액세스해야 하는 경우에는 로컬 스토리지를 사용하는게 좋다.
JWT를 저장하기 위한 최적의 방법은 프로젝트의 요구 사항 및 보안 고려 사항에 따라 다를 수 있다.
일부 개발자는 쿠키의 httpOnly 옵션을 활용하여 요청에 자동으로 쿠키를 포함시킬 수 있어 코드를 더 간결하게 작성할 수 있어서 쿠키를 선호하기도 하고,
어떤 개발자는 로컬 스토리지가 간단한 JavaScript로 다룰 수 있어서 개발 단계에서 로컬 스토리지를 선호하기도 한다.
백엔드 API 개발자와 소통이 가능한 경우,
Refresh Token
을 'httpOnly' 쿠키로 설정하고, 페이지 새로고침 시마다 Refresh Token을 요청에 담아서 새로운 Access Token을 발급 받으면,
공격자가 Refresh Token을 CSRF 공격으로 요청을 위조하여 서버 동작을 조작하는데 사용하더라도 공격자는 응답으로 받아온 Access Token
을 알 수 없으므로 보안이 강화된다.
CSRF 공격은 피해자의 컴퓨터를 제어할 수 있는 것이 아니고 요청을 위조하여 피해자가 의도하지 않은
서버 동작을 일으키는 공격 방법이기 때문이다.
이 방법은 쿠키를 사용하여 XSS 공격을 막고, Refresh Token을 통해 CSRF 공격 막을 수 있다.
Access Token과 Refresh Token은 일반적으로 로그인 또는 인증 요청의 응답에 함께 반환된다. 일반적으로 JSON 형식의 응답 데이터에 포함되어 클라이언트에게 전달된다. 클라이언트는 이러한 토큰을 저장하고 API 요청 시 헤더 또는 요청 매개변수에 넣어서 사용한다.
프론트에서 Access Token의 Payload에 있는 유효기간을 확인하고 바로 재발급 요청을 할 수도 있다.
사용자가 로그인 또는 인증을 요청하면, 서버는 사용자의 자격 증명(예: 사용자 이름과 비밀번호)을 확인한다.
인증이 성공하면 서버는 JWT 토큰을 생성한다. 일반적으로 Access Token과 Refresh Token 두 가지 종류의 JWT 토큰이 생성되며 이 토큰은 보통 JSON 형식으로 인코딩되어 있다.
Access Token은 짧은 유효 기간을 가지며 API 요청의 사용자를 인증하는 데 사용된다.
Refresh Token은 일반적으로 Access Token보다 더 긴 유효 기간을 가지며, Access Token의 만료 시간이 지난 후에 새로운 Access Token을 얻는 데 사용된다.
서버는 클라이언트에게 생성된 JWT 토큰(Access Token과 Refresh Token)을 JSON 형식의 응답 데이터에 포함하여 함께 반환한다.
클라이언트는 이러한 토큰을 저장하고 API 요청 시 헤더 또는 요청 매개변수에 넣어서 사용한다.
기본적으로 브라우저에서 사용자가 인증(Authentication
)을 수행하면 서버에서는 사용자의 정보를 저장하고, 그 응답으로 JSESSIONID 라는 키를 이용해 클라이언트(사용자) 브라우저의 쿠키에 세션의 정보를 저장하게된다.
이후 클라이언트는 브라우저 쿠키에 저장된 JSESSIONID 로 저장된 세션 정보를 이용해 인가(Authrization)된 정보에 접근할 수 있게 된다.
사용자가 인증을 수행하면 서버에서는 토큰을 생성한 뒤에 저장하지 않고(stateless
) 토큰값을 사용자의 브라우저에게 응답한다.
이 토큰 값을 사용자가 인가된 사용자만 사용할 수 있는 서비스를 요청할 때 함께 보내게 되고, 서버에서 이 토큰을 의미 있는 값(보통은 사용자 정보)으로 해석하게 된다. 그리고 이 값으로 사용자를 인증하게 된다.
토큰은 username, user_id 등 사용자를 설명할 수 있는 데이터를 포함하게 된다. 이렇게 사용자를 설명할 수 있는 데이터를 클레임(claim) 이라고 한다.
JWT 토큰 구조는 HEADER.PAYLOAD.VERIFY_SIGNATURE
로 이루어져 있다.
각 데이터는 온점(.)으로 구분
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
VERIFY_SIGNATURE
에 사용한 암호화(해싱) 알고리즘(alg)과 토큰 타입(typ), key
의 id
등의 정보를 가지고 있다. 난해한 문자열처럼 보이지만 암호화된 값은 아니다.HEADER 정보
{
"typ": "JWT", # 토큰 타입
"alg": "HS256" # 알고리즘
}
claim
) 이라고 하고, 대부분의 경우 클레임에 username
또는 user_id
를 포함한다. 인증시에 payload에 있는 username
을 가져와서 사용자의 정보를 인증할 때 사용해야 하기 때문이다.또한 payload
에서 중요하게 살펴보아야 할 정보는 토큰 발행시간(iat
)와 토큰 만료시간(exp
)이다. 토큰의 만료 시간이 지나면 새로운 토큰을 발급받아야 하기 때문이다.
PAYLOAD 정보
{
"token_type": "access", # 토큰의 종류. 여기서는 access 토큰
"exp": 1656293275, # 토큰의 만료시간 (Numeric Date)
"iat": 1656293095, # 토큰의 발행시간 (Numeric Date)
"jti": "2b45ec59cb1e4da591f9f647cbb9f6a3", # json token id
"user_id": 1 # 실제 사용자의 id값
}
header
와 payload
는 암호화 되지 않고 단순히 Json → UTF-8 → Base64 형식
으로 변환된 데이터이다. 즉 header 와 payload 의 생성 자체는 너무 쉽고 누구나 만들 수 있는 데이터따라서 저 두개의 데이터만 있다면 토큰에 대한 진위여부 판단은 이루어질수 없게 된다.
그래서 JWT의 구조에서 가장 마지막에 있는 VERIFY SIGNATURE
는 토큰 자체의 진위여부
를 판단하는 용도로 사용한다.
VERIFY SIGNATURE
는 Base64UrlEncoding
된 header
와 payload
의 정보를 합친 뒤 SECRET_KEY
를 이용하여 Hash
를 생성하여 암호화 합니다.
$ pip install djangorestframework-simplejwt
settings.py
의 REST_FRAMEWORK
의 인증 방식을 변경(추가)[ settings.py ]
'DEFAULT_AUTHENTICATION_CLASSES': [
...
# JWT 인증 방식 추가하기
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
[ users/urls.py ]
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns = [
...
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
...
]
[ settings.py ]
INSTALLED_APPS = [
...
'rest_framework_simplejwt',
...
]
[ settings.py ]
from datetime import timedelta
...
SIMPLE_JWT = {
# Access 토큰 유효 시간 설정하기
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=720),
# Refresh 토큰 유효 시간 설정하기
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'BLACKLIST_AFTER_ROTATION': False,
'UPDATE_LAST_LOGIN': False,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'VERIFYING_KEY': None,
'AUDIENCE': None,
'ISSUER': None,
'JWK_URL': None,
'LEEWAY': 0,
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
'JTI_CLAIM': 'jti',
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
# 토큰 커스텀에 쓰일 Serializer 설정
"TOKEN_OBTAIN_SERIALIZER": "users.serializers.CustomTokenObtainPairSerializer", # users앱의 CustomTokenObtainPairSerializer에 커스텀 내용 정의
"TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer",
"TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer",
"TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer",
"SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer",
"SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer",
}
🥚🐣🐤🐥🐓🐔