내가 원한 건 DRF과 React를 연동해서 로그인 정보와 유저 모델을 컨트롤하는 것이었는데,
진짜 너무 어려워서 쓰던 게시물 다 날리고 DRF로만 우선 공부하고,
React를 빠른 시일 내에 공부해서 다시 시도해보기로 했다.
(내가 구현하지 못 하는 게 존재하는 걸 납득할 수가 없다...)
코드를 전체적으로 갈아 엎었다.
회원가입 후에 로그인 하면서 토큰을 발급받고, 비밀번호를 바꾸었다가 로그인을 다시 하려니
정상적으로 처리가 안 되어서 한참을 헤맸는데..지금 생각해보니까 그냥 update 기능이 문제였던 것 같기도..
처음부터 다시 싹 다 만들면서 깔끔하게 정리할 필요가 있다.
일단 지금은 힘겹게 구현에 성공한 것들을 잠시 자축하면서 정리해놓고 다시 처음부터 해보자.
목차
1. 시작하기에 앞서...
2. Register
3. Login
4. Password Change & Profile Update
5. Logout
6. 참고자료
1. 시작하기에 앞서..
serializers 모델을 만들 때, create함수와 save함수의 차이를 모르고 코드를 작성하다가
계속 오류가 나서 찾아보니 둘은 엄연히 다른 놈이었다.
시작하기 전에 rest_framework안의 serializers.py를 조금 뜯어봤다.
나중에 이 아래내용은 serializer을 뜯어보는 과정으로써 따로 포스팅을 다룰 예정이다.
(혹시 이 정신없는 포스팅을 따라하고 계신 분이 있다면, 이 부분은 패스하는 게 나을지도)
나는 serializers.py 안에서 ModelSerializer을 상속해 오버라이딩시키는 모델링을 했으므로
ModelSerializer Class 안을 조금 들여다 보면 이렇게 적혀있다.
class ModelSerializer(Serializer):
Serializer을 상속받고 있으며
def create(self, validated_data):
"""
We have a bit of extra checking around this in order to provide
descriptive messages when something goes wrong, but this method is
essentially just:
return ExampleModel.objects.create(**validated_data)
If there are many to many fields present on the instance then they
cannot be set until the model is instantiated, in which case the
implementation is like so:
example_relationship = validated_data.pop('example_relationship')
instance = ExampleModel.objects.create(**validated_data)
instance.example_relationship = example_relationship
return instance
The default implementation also does not handle nested relationships.
If you want to support writable nested relationships you'll need
to write an explicit `.create()` method.
"""
raise_errors_on_nested_writes('create', self, validated_data)
ModelClass = self.Meta.model
# Remove many-to-many relationships from validated_data.
# They are not valid arguments to the default `.create()` method,
# as they require that the instance has already been saved.
info = model_meta.get_field_info(ModelClass)
many_to_many = {}
for field_name, relation_info in info.relations.items():
if relation_info.to_many and (field_name in validated_data):
many_to_many[field_name] = validated_data.pop(field_name)
try:
instance = ModelClass._default_manager.create(**validated_data)
except TypeError:
tb = traceback.format_exc()
msg = (
'Got a `TypeError` when calling `%s.%s.create()`. '
'This may be because you have a writable field on the '
'serializer class that is not a valid argument to '
'`%s.%s.create()`. You may need to make the field '
'read-only, or override the %s.create() method to handle '
'this correctly.\nOriginal exception was:\n %s' %
(
ModelClass.__name__,
ModelClass._default_manager.name,
ModelClass.__name__,
ModelClass._default_manager.name,
self.__class__.__name__,
tb
)
)
raise TypeError(msg)
# Save many-to-many relationships after the instance is created.
if many_to_many:
for field_name, value in many_to_many.items():
field = getattr(instance, field_name)
field.set(value)
return instance
아직까지 무슨 소리인지 잘 모르겠다. update 함수도 한 번 까볼까?
def update(self, instance, validated_data):
raise_errors_on_nested_writes('update', self, validated_data)
info = model_meta.get_field_info(instance)
# Simply set each attribute on the instance, and then save it.
# Note that unlike `.create()` we don't need to treat many-to-many
# relationships as being a special case. During updates we already
# have an instance pk for the relationships to be associated with.
m2m_fields = []
for attr, value in validated_data.items():
if attr in info.relations and info.relations[attr].to_many:
m2m_fields.append((attr, value))
else:
setattr(instance, attr, value)
instance.save()
# Note that many-to-many fields are set after updating instance.
# Setting m2m fields triggers signals which could potentially change
# updated instance and we do not want it to collide with .update()
for attr, value in m2m_fields:
field = getattr(instance, attr)
field.set(value)
return instance
얘도 잘 모르겠으니 일단 패스하자.
그렇다면 상속받고 있는 Serializer 클래스도 한 번 살펴보자.
class Serializer(BaseSerializer, metaclass=SerializerMetaclass):
BaseSerializer을 상속받고 있고 BaseSerializer까지 내려가보면 재밌는 내용이 하나 있다.
class BaseSerializer(Field):
...
def update(self, instance, validated_data):
raise NotImplementedError('`update()` must be implemented.')
def create(self, validated_data):
raise NotImplementedError('`create()` must be implemented.')
def save(self, **kwargs):
...
... a lot of assertions and safety checks ...
...
validated_data = dict(
list(self.validated_data.items()) +
list(kwargs.items())
)
if self.instance is not None:
self.instance = self.update(self.instance, validated_data)
....
else:
self.instance = self.create(validated_data)
...
return self.instance
드디어 save함수를 찾아냈는데, 조건문을 살펴보면
self.instance를 지정하면 update 메서드를 통해서 저장되고,
self.instance를 지정하지 않으면 create 메서드를 통해서 저장된다는 내용이다.
구체적인 하위 클래스로 내려가보면 이렇게 적혀있다.
def create(self, validated_data):
...
... some stuff happening
...
try:
# Here is the important part! Creating new object!
instance = ModelClass.objects.create(**validated_data)
except TypeError:
raise TypeError(msg)
# Save many-to-many relationships after the instance is created.
if many_to_many:
for field_name, value in many_to_many.items():
set_many(instance, field_name, value)
return instance
def update(self, instance, validated_data):
raise_errors_on_nested_writes('update', self, validated_data)
info = model_meta.get_field_info(instance)
# Simply set each attribute on the instance, and then save it.
# Note that unlike `.create()` we don't need to treat many-to-many
# relationships as being a special case. During updates we already
# have an instance pk for the relationships to be associated with.
for attr, value in validated_data.items():
if attr in info.relations and info.relations[attr].to_many:
set_many(instance, attr, value)
else:
setattr(instance, attr, value)
instance.save()
return instance
둘다 최종적으로 instance를 반환하긴 하지만 update는 기존의 instance를 받아오고 있고,
create함수는 ModelClass.objects.create(**validated_data)로 새롭게 instance값을 저장하고 있다.
대충 흐름만 짚고 넘어가자면 create와 update 메서드는 save메서드에서
인스턴스를 생성/수정하는 동작을 정의할 때 사용되는 메서드이다.
view에서 해당 serializer에 대해 is_valid()함수를 실행하면 거쳐간다.
그런데 ModelSerializer에선 제일 처음 확인했다시피 create, update 메서드가 이미 정의되어 있기 때문에
세부 구현이 필요한 게 아닌 이상 굳이 오버라이드하지 않아도 된다는 의미.
덕분에 처음에 뭣도 모르고 따라하다가 다른 여러 블로그 참고하는데,
에러가 미친 듯이 떠서 굉장히 당황스러웠다. ㅎㅎ
2. Register
회원가입을 하면서 유효성(validate) 검사를 통해 원래는 이메일 중복 검사와
password 재확인 기능까지 넣으려고 했는데 후자가 생각보다 호락호락하지가 않다.
Model에 password2 field를 추가하지 않는 방법으로 구현하고 싶은데, 일단 이건 좀 더 연구가 필요하다.
serializers.py
class RegisterUserSerializer(serializers.ModelSerializer):
# password2 = serializers.CharField()
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
def validate(self, attrs): # 중복 체크
email = attrs['email']
if NewUser.objects.filter(email=email).exists():
raise serializers.ValidationError("user already exists")
return attrs
validate 함수가 추가 되었다. 모든 유저 테이블의 속성 정보 중에서 email 레코드를 불러와
현재 입력받은 이메일과 일치하는 것이 있다면 예외처리를 해주었다.
views.py
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework.permissions import AllowAny, IsAuthenticated
from .serializers importnRegisterUserSerializer
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()
# create token
# token = TokenObtainPairSerializer.get_token(newuser)
# refresh_token = str(token)
# access_token = str(token.access_token)
response = Response(
{
"user": reg_serializer.data,
"message": "register success",
# "token": {
# "access": access_token,
# "refresh": refresh_token,
# },
},
status=status.HTTP_201_CREATED,
)
# response.set_cookie("access", access_token, httponly=True)
# response.set_cookie("refresh", refresh_token, httponly=True)
if newuser:
return response
return Response(reg_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
urls.py
from django.urls import path
from .views import (
CustomUserCreate,
)
app_name = 'users'
urlpatterns = [
path('register/', CustomUserCreate.as_view(), name="create_user"),
]
원래 register 단계에서 토큰을 발급받게 했었는데, 굳이 필요없는 것 같다.
이때 내 기억엔 포스트 맨을 아직 능숙하게 다루지 못 해서 샷건을 있는 대로 치다가
디버깅 하듯이 하나하나 다 확인해보겠다고 넣어놓은 코드 같다.
url로 회원 정보를 쏴주니 무사히 회원가입이 되었다.
만약 여기서 한 번 더 똑같은 정보로 회원가입을 시도하면 어떻게 될까??
이미 존재하는 유저이므로 400번대 에러를 띄우고 회원가입이 되지 않음을 확인할 수 있다.
3. Login
로그인 구현에서는 access와 refresh token을 발급받아야 한다.
이게 처음 해보는 거라 생각보다 만만치가 않다.
그리고 완벽하게 구현했다고 생각되지도 않는 게 로그인을 할 때마다 refresh token이 바뀐다.
아마 is_active 속성을 이용해서 이미 로그인 되어 있는 계정은 로그인 하지 않고,
그대로 유저를 반환하면 되지 않을까? 라는 생각이 든다.
근데 이건 애초에 리액트에서 해야 하는 거 아닌가..api에서 함부로 토큰을 저장하는 것도 이상하다.
serializers.py
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = NewUser
fields = "__all__"
views.py
class CustomUserLogin(APIView):
permission_classes = [ AllowAny ]
def post(self, request):
user = authenticate(
email = request.data.get("email"),
password = request.data.get("password"),
)
if user:
login_serializer = UserSerializer(user)
token = TokenObtainPairSerializer.get_token(user)
refresh_token = str(token)
access_token = str(token.access_token)
response = Response(
{
"user": login_serializer.data,
"message": "login success",
"token": {
"access": access_token,
"refresh": refresh_token,
},
},
status=status.HTTP_200_OK,
)
return response
return Response(status=status.HTTP_400_BAD_REQUEST)
urls.py
from django.urls import path
from .views import (
CustomUserCreate,
CustomUserLogin,
)
from rest_framework_simplejwt.views import TokenRefreshView
app_name = 'users'
urlpatterns = [
path('register/', CustomUserCreate.as_view(), name="create_user"),
path('login/', CustomUserLogin.as_view(), name="token_obtain_pair"),
path('login/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
모든 정보를 출력하게 했더니 너무 길어서 제대로 보이지도 않는다.
어쨌든 access token과 refresh token이 무사히 발급되었음을 확인할 수 있다.
그럼 이제 access token을 복사하자.
참고로 틀린 정보를 입력하면 Bad Request가 뜬다.
아, 참고로 access token만료로 재발급받아야 하는 경우에는 simplejwt에서 지원하는 경로로 접근하면 된다.
urls.py를 확인해보면 알겠지만 "login/refresh/"로 들어가면 가지고 있는 refresh 키로 재발급된다.
refresh키가 만료되면 다시 로그인 해야 할 필요가 있다.
4. Password Change & Profile Update
상당히 까다로웠다.
어차피 패스워드를 바꾸면 보통 다시 로그인 하도록 만드니까 token을 관리할 필요는 없지만,
(그냥 password를 바꿔버리면 기존의 토큰으로는 정보가 불일치해서 접근이 안 됨.)
기존의 패스워드를 old_password와 비교하고, 유효하면 new_password로 갈아치기 해줘야 한다.
serializer.py
from django.contrib.auth.password_validation import validate_password
class ChangePasswordSerializer(serializers.ModelSerializer):
old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True)
class Meta:
model = NewUser
fields = ('old_password', 'new_password')
extra_kwargs = {'new_password': {'write_only': True, 'required': True},
'old_password': {'write_only': True, 'required': True}}
def validate_new_password(self, value):
validate_password(value)
return value
old_password, new_password 둘 다 Model에 포함되지 않는 속성이므로 적어주어야 함.
views.py
class ChangePasswordView(generics.UpdateAPIView):
permission_classes = [ IsAuthenticated ]
def get_object(self, queryset=None):
obj = self.request.user
return obj
def update(self, request, *args, **kwargs):
self.object = self.get_object()
serializer = ChangePasswordSerializer(data=request.data)
if serializer.is_valid():
# Check old password
if not self.object.check_password(serializer.data.get("old_password")):
return Response({"old_password": ["Wrong password."]}, status=status.HTTP_400_BAD_REQUEST)
# set_password also hashes the password that the user will get
self.object.set_password(serializer.data.get("new_password"))
self.object.save()
response = {
'status': 'success',
'message': 'Password updated successfully',
}
return Response(response, status=status.HTTP_200_OK,)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
urls.py
from django.urls import path
from .views import (
CustomUserCreate,
CustomUserLogin,
ChangePasswordView,
)
from rest_framework_simplejwt.views import TokenRefreshView
app_name = 'users'
urlpatterns = [
path('register/', CustomUserCreate.as_view(), name="create_user"),
path('login/', CustomUserLogin.as_view(), name="token_obtain_pair"),
path('login/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('change_pw/<int:pk>/', ChangePasswordView.as_view(), name='change_pw'),
]
Headers에 KEY값으로 Authorization을 입력하고
VALUE에 "JWT + accesskey"문자열을 넣어준다. 사이에 무조건 공백이 포함되어야 한다.
방금 생각난 건데 리액트 연동할 때 이 내용을 들었었는데,
JWT와 access 값 사이에 공백을 주는 이유를 드디어 알게 되었다. (박수)
이제 패스워드를 수정해보자.
경로는 "change_pw/<int:pk>/" 였으므로 유저가 내 기준에서는 9번이었음을 기억하자.
참고로 이번에는 PUT 메서드를 이용해서 보내야 한다!!!!
old_password를 고의로 틀려서 예외처리에 걸리는 걸 보여주려 했는데
new_password의 invalid가 먼저 걸렸다. 패스워드가 너무 짧고 평범하다고 한다.
정작 처음에는 체크하지 않았던 항목이긴 한데 ㅋㅋㅋㅋㅋ
이번에는 new_password를 좀 정성들여 썼더니 드디어 old_password가 걸렸다.
기존의 비밀번호를 잘못 입력하면 예외처리한다.
모두 성공적으로 입력하면 그제서야 password가 정상적으로 바뀐다.
로그인으로 확인해보자.
Headers에 입력했던 access token을 해제해주는 것을 잊지말자.
계정의 비밀번호를 바꾸었으니 재로그인 하기 위해 기존 토큰은 버린다.
보시다시피 초기 비밀번호로는 로그인이 안 된다.
수정한 비밀번호로 로그인하면 다시 무사히 접근할 수 있다.
와, 이게 제일 힘들었다 진짜.
5. Logout
로그아웃은 post한 유저의 refresh token을 날려버리면 된다.
이건 근데 거의 어디선가 가져다 쓴 코드를 마개조한 거라 상속을 ModelSerializer가 아니라
그냥 Serializer을 받아버렸는데 잘 동작한다;
둘의 차이는 대강 알고 있긴 한데, 이게 왜 되는 건지 잘 모르겠다.
serializer.py
class RefreshTokenSerializer(serializers.Serializer):
refresh = serializers.CharField()
default_error_messages = {
'bad_token': 'Token is invalid or expired'
}
def validate(self, attrs):
self.token = attrs['refresh']
return attrs
def save(self, **kwargs):
try:
RefreshToken(self.token).blacklist()
except TokenError:
self.fail('bad_token')
views.py
class CustomUserLogout(APIView):
permission_classes = [ IsAuthenticated ]
def post(self, request, *args):
user = RefreshTokenSerializer(data=request.data)
user.is_valid(raise_exception=True)
user.save()
return Response(
{
"message": "logout success"
}, status=status.HTTP_204_NO_CONTENT)
urls.py
from django.urls import path
from .views import (
CustomUserCreate,
CustomUserLogin,
ChangePasswordView,
CustomUserLogout
)
from rest_framework_simplejwt.views import TokenRefreshView
app_name = 'users'
urlpatterns = [
path('register/', CustomUserCreate.as_view(), name="create_user"),
path('login/', CustomUserLogin.as_view(), name="token_obtain_pair"),
path('login/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('change_pw/<int:pk>/', ChangePasswordView.as_view(), name='change_pw'),
path('logout/', CustomUserLogout.as_view(), name="logout")
]
로그인 할 때 받았던 access token을 header에 넣고, refresh 키와 값을 "user/logout/"으로 POST 하면
로그아웃이 되어야 한다.
과연 성공적으로 된 것이 맞을까?
그렇다면 여기서 다시 한 번 로그아웃을 시도 했을 때, default_error_messages에 걸려야 한다.
ㅎㅎ 토큰이 잘 날아갔음을 확인할 수 있다.
다음 포스팅은 이메일 인증 혹은 serializer에 대해 파헤쳐보는 걸로 해봐야겠다.
6. 참고자료