JWT token 이론 정리가 끝나면 본격적으로 로그인 기능을 구현해봐야겠다.
DRF를 이해하고 포스팅하는 것이 아니라, 속성으로 머리에 때려넣고 어떻게 잘 반죽해가며 쓰는 중이라
틀린 내용도 많을 수 있지만 어쨌든 정면으로 DRF를 찢어보자. 😎
목차
1. Cookie, Session, Token
2. JWT (Json Web Tokenization)
3. Access & Refresh Token 발급받기
4. APIView
5. 참고자료
1. Cookie, Session, Token
생각을 해보자. 사람들이 종종 착각하곤 하지만 어쨌든 웹은 '정적인 상태'이다.
블로그 주인인 내가 포스팅을 하기 위해서는 그만한 권한(auth)이 있다는 것을 인증할 수 있어야 하므로,
로그인 상태가 지속적으로 유지되어야 한다.
따라서 페이지를 이동할 때마다 내가 로그인 상태고, 그에 따른 권한을 인증받기 위해서는
페이지를 이동할 때마다 인증을 해야하는데 그때그때 사용자가 ID,PW를 날리게 할 수는 없으니
쿠키니 세션이니 이런 것들을 사용해서 정보를 주고 받게 했다.
(1) Cookie
어느 웹 사이트에서 회원가입을 하면 내 정보가 DB에 저장된다.
이후 클라이언트가 서버에 요청(request)을 보내면 응답(response)으로써 페이지에 대한 정보를 보낼텐데
유저가 1억 명이라고 가정했을 때, DB를 매번 전탐색하는 과정은 너무 비효율적이므로
클라이언트의 정보를 담은 쿠키(Cookie)를 같이 전송하여 클라이언트의 브라우저에 저장한다.
그러면 다음에 클라이언트가 또 서버에 요청을 보낼 때는 브라우저에 저장된 쿠키를 같이 보내게 될 것이고,
서버 입장에서는 굳이 DB를 재탐색하지 않고 이미 인증된 유저라는 것을 인지할 수 있는 것이다.
문제는 이 쿠키라는 놈이 문제가 많다.
우선 보안상 이슈가 가장 크다. 어떤 현직 개발자 분께서 말씀해주시길 예전에 PC방을 돌아다니면서
브라우저에 저장된 쿠키 정보를 빼내어 악용했던 범죄도 많았다고 한다.
그래서 요새는 쿠키를 브라우저에 저장할 때, 사이트에서 허락을 구하는 이유가 그 때문이다.
그 외에도 도메인에 따라 제한되는 탓에 다른 브라우저간 공유가 안 되고,
쿠키 사이즈가 4KB라는 제한이 걸려있어서 충분한 데이터를 담지 못 하는 문제들이 많다.
그래서 유저의 브라우저에 데이터를 정보를 저장하지 않으면서,
그때마다 클라이언트의 권한을 인증할 수 있을 만한 방법을 모색해야 했는데
그렇게 나온 개념이 바로 세션과 토큰이다.
그럼 둘의 차이는 무엇일까?
(2) Session
세션은 Stateful 서버고, 반대로 JWT는 Stateless 서버라고 불린다.
JWT는 밑에서 다시 다루도록 하고 세션부터 이야기 해보자.
쿠키를 이용해서 클라이언트 브라우저에 정보를 저장하지 못 하다보니 결국 문제가 처음으로 돌아왔다.
DB를 전부 탐색하려니 이건 아무리 봐도 아닌 것 같아서, 보안상의 이슈를 줄이면서 동시에
'클라이언트의 인증을 유지해야 한다'라는 고민의 결과물이 바로 상태를 유지하는 것이었다.
클라이언트가 서버에 ID, PW 정보를 보내서 로그인을 요청하면
서버는 존재하는 유저라고 판단했을 경우에 Session DB에 유저 정보를 기록한다.
그러면 Session DB는 해당 유저를 식별할 유니크 세션 아이디를 별도로 생성하여 쿠키에 담아 서버에 보낸다.
그럼 서버는 클라이언트에 해당 쿠키를 보내준다.
자, 여기서 "쿠키가 보안 이슈 때문에 세션을 쓴다면서 왜 또 쿠키를 쓰는 건가요?"라고 제기할 수 있지만,
이전과는 달리 지금은 쿠키에 담긴 것은 특수한 세션 ID고 유저 정보가 아니다.
즉, 세션 DB에 유저의 정보를 자물쇠로 잠구고 키를 유저에게 쥐어준 것이라고 생각하면 된다.
그러면 나중에 다른 페이지로 이동하여 또 서버에 요청(request)을 하게 된다면,
클라이언트(정확하게 따지자면 브라우저)는 서버에 세션ID가 들어있는 쿠키를 건넬 것이고
서버는 세션 DB에 해당 ID가 있다면 유저 정보를 확인하여 적절한 권한을 던져주면 되는 것이다.
(없으면 401 에러 반환)
세션을 활용하게 된다면 쿠키가 탈취당한다고 하더라고 세션 DB를 전부 날려버리면 그만이다.
반증으로는 세션 DB에 문제가 생기면 정상적인 유저들도 인증을 하지 못하는 불상사가 난다.
또한, stateful은 http 프로토콜의 장점인 stateless를 위배하는데,
대체 아까부터 stateful이니 stateless니 하는 건 무슨 소리일까?
가장 처음에 웹은 정적이라고 언급했었다.
따라서 페이지를 이동한다고 해서 로그아웃되는 불상사를 막기위해 쿠키니 세션이니를 사용한 건데
세션을 활용하면 DB에 유저 정보를 담아둠으로써 클라이언트의 상태를 계속 유지하고 있다.
해당 세션의 유효시간이 지나기 전까지는 DB에 계속 담겨져 있으며,
브라우저에서 요청을 할 때마다 DB를 뒤져서 해당 유저의 정보를 꺼내오는 등
벌써부터 보이는 문제점만 서버 컴퓨터 메모리 과부화로 인한 이슈가 눈에 선하다.
(로그인한 유저가 늘어날 수록 세션 DB의 리소스 요구량이 많아지므로 부담이 심해진다.)
이렇게 유저 상태 정보를 서버가 유지하는 것을 Steteful하다고 한다.
하지만 Stateless 서버는 유저가 서버에 요청하는 것이 이전 요청과 독립적이어야 한다.
그렇다면 대체 어떻게 그런 요청을 서버가 수행할 수 있을까? 라는 의문점에서 나온 것이 바로
JWT(Json Web Tokenization)이다.
하지만 그 전에 토큰(Token)에 대해서 알고 넘어가자.
토큰은 사실 쿠키랑 사용하는 목적은 같은데, 쿠키는 브라우저에서만 사용할 수 있다.
즉, 안드로이드나 IOS에서 사용이 불가능하다.
따라서 웹이 아닌, 앱에서 쿠키처럼 사용할 무언가가 필요해서 나온 개념이다.
(3) Token
토큰은 그냥..겁나 이상하게 생긴 문자열(String)이다.
쿠키처럼 클라이언트가 토큰을 서버에 던지면, 서버는 세션 DB에서 토큰과 일치하는 유저를 찾아 반환한다.
2. JWT (Json Web Tokenization)
클라이언트가 서버에 ID, PW를 넘겨서 로그인하는 것까지는 같지만
서버가 세션 DB를 따로 생성하지는 않는다.
해당 유저의 계정이 존재한다면 서버는 해당 클라이언트가 유효한 권한을 가진다는 '사인'을 보내줄 것이고,
유저는 다음 요청부터 토큰을 같이 보내면 되는 것이다.
즉, 세션 DB를 따로 만들지 않아도 되므로 서버 컴퓨터의 부담도 없어지면서
유저 개인 정보를 브라우저에 담지 않으므로 보안상의 이슈도 해결할 수 있게 된 셈이다.
여기서 잠깐! 그렇다면 세션보다 JWT가 무조건 더 좋은 건가요?
사실 그렇지는 않다. 세션의 가장 강력한 기능은 유저 계정을 관리할 때 모든 정보를 담기 때문에
새로운 기능들을 다양하게 추가할 수도 있다.
다만 그걸 관리하기 위해서 DB를 구매하고 서버를 유지하는데 너무 막대한 비용과 노력이 들어가므로
'일반적으로' 사용하지 않는 것 뿐이지 제법 규모있는 웹 사이트라면 DB사서 세션으로 관리하는 게
훨씬 효율적일 수 있다.
JWT는 토큰을 추적할 필요 없이 생성 후 유효기간동안 적합한 토큰임을 판단만하면 되므로
DB가 필요없다는 것이 가장 큰 장점인 것이다.
JWT의 구성요소는 3가지로써 점(.)으로 구분되어 있다.
- Header
- "alg" : "HS512" → 어떤 해싱 알고리즘을 사용할 것인가?
- "typ" : "JWT" → 토큰의 유형이 무엇인가? (JWT는 토큰 유형 중 하나일 뿐..)
- Payload (종류가 더 있긴 하지만, 모두 사용할 필요는 없다.)
- "sub" : "123456" → 토큰 제목
- "name" : "yang" → 토큰 제목
- "iat" → 토큰 발급 시간 (Issued At)
- "exp" → 토큰 만료 시간 (Expiration Time)
- "aud" → 토큰 대상자 (Audience)
- "iss" → 토큰 발급자 (Issuer)
- Signature
JWT를 사용할 때 주의할 점은 절대 민감한 정보를 Header나 Payload에 담아서는 안 된다는 것이다.
암호화를 시켜버리면 http 요청마다 복호화 과정을 거쳐야 하는데, 그러기엔 수지가 안 맞다.
어쨌든 JWT는 누구나 내용을 열어서 확인할 수 있기 때문에 개인 정보를 보내지는 말자.
Signature은 Header와 Payload를 각각 인코딩 값을 합치고 시크릿 키를 이용하여
해쉬 및 base64 인코딩을 통해 생성한다고 한다. (your-256-bit-secret)
즉, Signature은 서버가 가지고 있는 키를 통해서 암호를 풀 수 있으므로 다른 클라이언트가
토큰을 탈취한다고 해도 암호를 풀지 못 한다면 의미가 없어지게 된다.
JWT.IO
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.
jwt.io
직접 확인해볼 수 있다.
Access Token과 Refresh Token이 있다.
Access Token은 인증을 위한 JWT이면서 유효기간이 10~20분밖에 되지 않는다.
이는 보안 이슈 때문인데, 이 토큰을 탈취당하면 아무래도 보안 상 취약해질 수밖에 없다.
그렇다고 클라이언트한테 10분마다 로그인하라고 할 수는 없으니 생각한 것이 Refresh Token이다.
Refresh Token은 유효기간이 길다.
한 10일 정도 된다고 치면, Access Token을 재발급 받을 수 있는 토큰이다.
즉, Refresh Token이 유효하다면 Access Token을 그때그때 재발급 받고
Access Token으로 서버에 요청하면 되는 것이다.
반대로 로그아웃을 할 때는 모든 토큰을 없애버리면 끝난다.
3. Access & Refresh Token 발급받기
JWT를 사용하기 위해서 DRF와 React에서 몇 가지 설정을 요구하는데, 차례차례 해보자.
Getting started — Simple JWT 5.2.0.post4+gc707cf8 documentation
Cryptographic Dependencies (Optional) If you are planning on encoding or decoding tokens using certain digital signature algorithms (i.e. RSA and ECDSA; visit PyJWT for other algorithms), you will need to install the cryptography library. This can be insta
django-rest-framework-simplejwt.readthedocs.io
시키는 대로 하면 대부분 구현가능하다.
pip install djangorestframework djangorestframework-simplejwt
bash에 위의 명령어를 입력하여 jwt를 가상환경에 설치한다.
REST_FRAMEWORK = {
...
'DEFAULT_AUTHENTICATION_CLASSES': (
...
'rest_framework_simplejwt.authentication.JWTAuthentication',
)
...
}
settings.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'),
...
]
urls.py에는 이렇게 추가해주면 되는데 JWT가 알아서 토큰도 처리해준단다..세상 편하다.
상단에 import 했을 때, 노란줄이 뜰 수 있는데 일단 무시하자.
참고로 simplejwt말고 jwt는 장고 버전 3까지만 지원한다. 나는 현재 버전 4이상이므로 simplejwt를 사용한다.
urlpatterns를 확인해보면 api/token/ 경로로 들어가면 토큰을 받을 수 있다고 한다.
과연 토큰이 정상적으로 발급될 수 있을까? Post man으로 확인해보자.
해당 url로 request를 보냈더니 아이디와 비밀번호가 필요하다고 한다.
그렇다..이제 드디어 유저 form을 만들 때가 왔다.
python mange.py startapp users로 회원 관리를 맡을 앱을 만들자.
사실 이 부분은 Django에서 했던 부분과 별반 다를 바 없는 부분이므로 자세히 다루지 않을 예정이다.
로그인 폼 구현은 이후에 DRF가 아니라 Django 관련 포스팅을 하게 되면 그 때 다루겠다.
users.models.py
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
class CustomAccountManger(BaseUserManager):
def create_superuser(self, email, user_name, first_name, password, **other_fields):
other_fields.setdefault('is_staff', True)
other_fields.setdefault('is_superuser', True)
other_fields.setdefault('is_active', True)
if other_fields.get('is_staff') is not True:
raise ValueError(
'Superuser must be assigned to is_staff=True.')
if other_fields.get('is_superuser') is not True:
raise ValueError(
'Superuser must be assigned to is_superuser=True.')
return self.create_user(email, user_name, first_name, password, **other_fields)
def create_user(self, email, user_name, first_name, password, **other_fields):
if not email:
raise ValueError(_('You must provide an email address'))
email = self.normalize_email(email)
user = self.model(email=email, user_name=user_name,
first_name=first_name, **other_fields)
user.set_password(password)
user.save()
class NewUser(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(_('email address'), unique=True)
user_name = models.CharField(max_length=150, unique=True)
first_name = models.CharField(max_length=150, blank=True)
start_date = models.DateTimeField(default=timezone.now)
about = models.TextField(_('about'), max_length=500, blank=True)
is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True) # False면 email을 확인하기 전까진 비활성화 상태
objects = CustomAccountManger() # 일반/슈퍼 user 모두 처리
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ["user_name", "first_name"] # 필수 항목
def __str__(self):
return self.user_name
django로 로그인 폼 구현을 공부해본 사람이라면 충분히 이해하고 넘어갈 수 있는 부분이다.
이제 유저 모델을 등록하자.
settings.py에 코드를 입력해주고 migrate해준다.
여기서 에러가 뜰 텐데 db.sqlite3와 blog.migrations.0001_initial.py를 지워버리고
blog.models.py에서 import한 User대신에 settings에서 경로를 정해주도록 하자.
기존의 유저 객체를 이용해야 하는데 서로 충돌이 나서 이런 거니까
우리가 만든 모델을 이용하게끔 만들어주자.
이번에 다시 makemigrations을 하면 성공적으로 migrate가 만들어질 것이다.
기존 db를 다 날려버린 상태이므로 새롭게 createsuperuser을 해주면
이번엔 반드시 email을 적어주어야 한다. (내가 모델을 그렇게 만들었으니까..)
대충 아무렇게나 superuser를 하나 만들어주고 서버 실행 후 다시 Postman으로 돌아가자.
JSON타입으로 email과 password를 입력해 api/token으로 request를 보내면
드디어 토큰을 발급받은 것을 확인할 수 있다.
from csv import list_dialects
from django.contrib import admin
from users.models import NewUser
from django.contrib.auth.admin import UserAdmin
from django.forms import TextInput, Textarea, CharField
from django import forms
from django.db import models
class UserAdminConfig(UserAdmin):
model = NewUsersearch_fields = ('email', 'user_name', 'first_name',)
search_fields = ('email', 'user_name', 'first_name', 'is_active', 'is_staff')
list_filter = ('email', 'user_name', 'first_name', 'is_active', 'is_staff')
ordering = ('-start_date',)
list_display = ('email', 'id', 'user_name', 'first_name', 'is_active', 'is_staff')
fieldsets = (
(None, {'fields': ('email', 'user_name', 'first_name',)}),
('Permissions', {'fields': ('is_staff', 'is_active')}),
('Personal', {'fields': ('about',)}),
)
formfield_overrides = {
models.TextField: {'widget': Textarea(attrs={'rows': 20, 'cols': 60})},
}
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields' : ('email', 'user_name', 'first_name',
'password1', 'password2', 'is_active', 'is_staff')}
),
)
admin.site.register(NewUser, UserAdminConfig)
users 모델을 admin 페이지에서 확인해야 하므로 admin.py에 위 코드를 입력하자.
현재 superuser의 ID는 1이다. 그렇다면 access 토큰 정보에서도 동일할까?
JWT 공식 홈페이지에서 발급받은 access 토큰을 디코드해보면 user_id가 1이 나옴을 확인할 수 있다.
4. APIView
이제 본격적으로 api를 직렬화(Serialize)하여 리액트와 연동시켜보자.
JWT 홈페이지의 setting 탭에 나오는 대로 settings.py에 입력한다.
읽어보면 대충 무슨 내용인지 알 수 있을 법한 것들 뿐이므로 자세한 내용은 다루지 않고 넘어가자.
아, 그래도 'AUTH_HEADER_TYPES'에 'JWT'를 추가하는 건 잊지 말자.
users.serializers.py
from rest_framework import serializers
from users.models import NewUser
class RegisterUserSerializer(serializers.ModelSerializer):
class Meta:
model = NewUser
fields = ('email', 'user_name', 'password')
extra_kwargs = {'password': {'write_only': True}}
def create(self, validated_data):
password = validated_data.pop('password', None)
instance = self.Meta.model(**validated_data)
if password is not None:
instance.set_password(password)
instance.save()
return instance
users.views.py
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import RegisterUserSerializer
from rest_framework.permissions import AllowAny
class CustomUserCreate(APIView):
permission_classes = [ AllowAny ]
def post(self, request):
reg_serializer = RegisterUserSerializer(data=request.data)
if reg_serializer.is_valid():
newuser = reg_serializer.save()
if newuser:
return Response(status=status.HTTP_201_CREATED)
return Response(reg_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
users.urls.py
from django.urls import path
from .views import CustomUserCreate
app_name = 'users'
urlpatterns = [
path('register/', CustomUserCreate.as_view(), name="create_user"),
]
myproject.urls.py
urlpatterns = [
...
path('api/user/', include('users.urls', namespace="users")),
...
]
다시 포스트맨을 켜서 회원가입을 해보자.
api/user/register로 이메일, 유저 이름, 비밀번호를 넘긴 후 어드민 페이지로 확인했을 때,
유저가 성공적으로 등록되면 성공한 것이다. 아무렇게나 정보를 입력하고 전송하자.
여기서 에러가 났었는데 URL 끝에 '/'를 꼭 잊어먹지 말고 붙여주도록 하자..
다행히 입력한 정보 그대로 무사히 DB에 저장되었음을 확인할 수 있다.
이렇게 되면 api는 모두 정상적으로 작동하고 있음을 의미하고 React에 연동만 하면
기본적인 회원가입 및 로그인 폼은 끝이 난다.
5. 참고자료
Django DRF JWT를 이용한 회원가입/로그인 구현 - (1)
최근에 장고 대신 DRF(Django Rest Framework)를 사용해서 백엔드 구축 과제를 진행하고 있는데요. 이전에 데이터 처리 및 DB용으로 장고를 사용하긴 했어도, DRF는 처음이다보니 익숙치 않네요 ㅎㅎ 그
moondol-ai.tistory.com
JWT(Json Web Token) 알아가기
jwt가 생겨난 이유부터 jwt의 실제 구조까지 | 사실 꾸준히 작성하고 싶었던 글이지만 JWT를 제대로 개념을 정리하고 구현을 진행해본 적이 없었는데 리얼월드 프로젝트를 진행하면서 JWT에 대한
brunch.co.kr
쉽게 알아보는 서버 인증 2편(Access Token + Refresh Token)
안녕하세요! 이전 포스팅에는 크게 세션/쿠키 인증, 토큰 기반 인증(대표적으로 JWT)에 대하여 알아보았습니다. 저희가 앱, 웹 혹은 서버 개발을 하면서 꼭 사용하게 되는 인증(Authorization)은 아주
tansfil.tistory.com