작성자 | 임혜진 |
일 시 | 2024. 9. 3 (화) 18:00 ~ 21:00 |
장 소 | 미래관 자율주행스튜디오 429호 |
참가자 명단 | 임혜진, 성창민, 이재영, 장원준, 김명원 |
사 진 |
하고자 하는 것: 이미 비밀번호를 변경했거나 하루가 지난 비밀번호 변경 링크에 대해서는 토큰이 만료되었다는 화면을 띄우고 싶음.
이렇게 처리해주지 않는다면 비밀번호 변경 화면에서 넘어가지 않아 사용자 입장에서는 비밀번호가 변경이 된건지 안된건지, 왜 화면이 안넘어가는지, 문제가 무엇인지 알 수 없기 때문임.
문제 상황: dispatch 함수를 추가하여 링크를 접속할 때 토큰에 대한 유효성 검사를 먼저 처리해주고자 하였음. 그러나 url에서 token을 가져오는 부분( token = kwargs.get('token') )에서는 알파벳과 숫자로 나열된 실제 token값이 아니라 'set-password'라는 값으로 받아와짐. (보안상의 이유로 django 내부적으로 처리되는 부분인 것 같음) 실제로 링크의 url을 확인해보아도 http://127.0.0.1:8000/api/users/password_reset_confirm/Mg/set-password/ 으로 되어있음. 따라서 올바른 token을 가져오지 못하니 유효성 검사에서도 올바르지 않은 토큰으로 인식함. 때문에 방금 생성한 올바른 링크도 만료되었다는 이미지가 표시됨.
기존 코드:
class PasswordResetRequestView(APIView):
def post(self, request):
email = request.data.get('email')
if not email:
return Response({'error': 'Email is required'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
return Response({'error': 'User with this email does not exist'}, status=status.HTTP_404_NOT_FOUND)
token = default_token_generator.make_token(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))
request.session['password_reset_token'] = token
current_site = get_current_site(request)
domain = request.get_host()
protocol = 'https' if request.is_secure() else 'http'
# 비밀번호 재설정 링크를 먼저 생성
reset_link = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token})
print('rest_link',reset_link)
reset_url = f'{protocol}://{domain}{reset_link}'
mail_subject = '[아이랑 아이(AI)랑] 비밀번호 재설정 이메일'
message = render_to_string('account/email/password_reset_email.html', {
'user': user,
'reset_url': reset_url,
})
send_mail(
mail_subject,
message,
settings.DEFAULT_FROM_EMAIL,
[email],
fail_silently=False,
html_message=message
)
return Response({'success': '비밀번호 재설정 이메일이 전송되었습니다.'}, status=status.HTTP_200_OK)
class CustomPasswordResetConfirmView(auth_views.PasswordResetConfirmView):
def dispatch(self, request, *args, **kwargs):
# URL에서 uidb64와 token을 가져오기
uidb64 = kwargs.get('uidb64')
token = kwargs.get('token')
# token = 'cbcb4b-b32713771304ae1dec2d1cfba2063f73'
print(f'uidb64: {uidb64}, token: {token}')
# uidb64를 디코드하여 user ID를 복원
try:
uid = urlsafe_base64_decode(uidb64).decode()
user = User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
user = None
# 토큰 유효성 검사
if user is not None and default_token_generator.check_token(user, token):
# 토큰이 유효하면, user와 token을 설정
self.user = user
self.token = token
else:
# 토큰이 유효하지 않으면, 오류 페이지로 리다이렉트
return render(request, 'account/email/password_reset_invalid.html')
# 나머지 dispatch 처리
return super().dispatch(request, *args, **kwargs)
시도1: 처음에는 올바르게 token값을 받아올 수 있는 경로에 대해서 고민함. 하지만 실패하였음. url에서 뽑아오는 방법밖에 없는 것 같은데 url에서는 token = kwargs.get('token') 이렇게 token값을 추출하면, set-password 밖에 받아와지지 않음
시도2: url로서 token 전달이 이루어지지 않아 다른 방법으로 token을 넘겨줄 수 있는 방법으로 시도하고자 하였음. 토큰을 생성하면 세션에 토큰 값을 저장해두고, CustomPasswordResetConfirmView에서 token을 읽을 때 url이 아닌 세션에서 토큰 값을 읽음.
토큰 생성 후 세션에 토큰 값 저장:
token = default_token_generator.make_token(user)
request.session['password_reset_token'] = token
세션에서 토큰을 읽음
token = request.session.get('password_reset_token')
해결 : 새로 발급한 토큰에 대해서는 링크 접속해서 비밀번호 변경 가능, 이미 사용한 링크에 대해서는 만료된 링크라는 화면 표시
전체 코드
class PasswordResetRequestView(APIView):
def post(self, request):
email = request.data.get('email')
if not email:
return Response({'error': 'Email is required'}, status=status.HTTP_400_BAD_REQUEST)
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
return Response({'error': 'User with this email does not exist'}, status=status.HTTP_404_NOT_FOUND)
token = default_token_generator.make_token(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))
request.session['password_reset_token'] = token
current_site = get_current_site(request)
domain = request.get_host()
protocol = 'https' if request.is_secure() else 'http'
# 비밀번호 재설정 링크를 먼저 생성
reset_link = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token})
reset_url = f'{protocol}://{domain}{reset_link}'
mail_subject = '[아이랑 아이(AI)랑] 비밀번호 재설정 이메일'
message = render_to_string('account/email/password_reset_email.html', {
'user': user,
'reset_url': reset_url,
})
send_mail(
mail_subject,
message,
settings.DEFAULT_FROM_EMAIL,
[email],
fail_silently=False,
html_message=message
)
return Response({'success': '비밀번호 재설정 이메일이 전송되었습니다.'}, status=status.HTTP_200_OK)
class CustomPasswordResetConfirmView(auth_views.PasswordResetConfirmView):
def dispatch(self, request, *args, **kwargs):
# URL에서 uidb64와 token을 가져오기
uidb64 = kwargs.get('uidb64')
token = request.session.get('password_reset_token')
# uidb64를 디코드하여 user ID를 복원
try:
uid = urlsafe_base64_decode(uidb64).decode()
user = User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
user = None
# 토큰 유효성 검사
if user is not None and default_token_generator.check_token(user, token):
# 토큰이 유효하면, user와 token을 설정
self.user = user
self.token = token
else:
# 토큰이 유효하지 않으면, 오류 페이지로 리다이렉트
return render(request, 'account/email/password_reset_invalid.html')
# 나머지 dispatch 처리
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
password = form.cleaned_data.get('new_password1')
password_regex = re.compile(r'^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,32}$')
if not password_regex.match(password):
form.add_error('new_password1', '비밀번호는 8-32자리여야 하며, 최소 하나의 문자, 숫자 및 특수 문자를 포함해야 합니다.')
return self.form_invalid(form)
form.save() # 비밀번호 저장
print("비밀번호 재설정이 완료되었습니다.")
messages.success(self.request, '비밀번호가 성공적으로 재설정되었습니다.')
return redirect('/api/users/password_reset/done/')
def form_invalid(self, form):
print("폼이 유효하지 않습니다. 오류:", form.errors)
messages.error(self.request, '비밀번호 재설정 중 오류가 발생했습니다. 다시 시도해주세요.')
return self.render_to_response(self.get_context_data(form=form))
아 다시 발생한 문제 상황:
세션에 저장하기 때문에
비밀번호 변경 링크전송, 다시 전송 -> 첫번째 링크 사용 가능, 두번째 링크 사용가능
둘중에 하나라도 사용시 둘다 만료
이 상태에서 세번째 링크 전송 -> 첫번째 혹은 두번째 링크 접속 시 접속 가능 -> 비밀번호 변경시 변경 이루어지지x, 화면 넘어가지 x
라이브러리의 원본 코드
class PasswordResetConfirmView(PasswordContextMixin, FormView):
form_class = SetPasswordForm
post_reset_login = False
post_reset_login_backend = None
reset_url_token = "set-password"
success_url = reverse_lazy("password_reset_complete")
template_name = "registration/password_reset_confirm.html"
title = _("Enter new password")
token_generator = default_token_generator
@method_decorator(sensitive_post_parameters())
@method_decorator(never_cache)
def dispatch(self, *args, **kwargs):
if "uidb64" not in kwargs or "token" not in kwargs:
raise ImproperlyConfigured(
"The URL path must contain 'uidb64' and 'token' parameters."
)
self.validlink = False
self.user = self.get_user(kwargs["uidb64"])
if self.user is not None:
token = kwargs["token"]
if token == self.reset_url_token:
session_token = self.request.session.get(INTERNAL_RESET_SESSION_TOKEN)
if self.token_generator.check_token(self.user, session_token):
# If the token is valid, display the password reset form.
self.validlink = True
return super().dispatch(*args, **kwargs)
else:
if self.token_generator.check_token(self.user, token):
# Store the token in the session and redirect to the
# password reset form at a URL without the token. That
# avoids the possibility of leaking the token in the
# HTTP Referer header.
self.request.session[INTERNAL_RESET_SESSION_TOKEN] = token
redirect_url = self.request.path.replace(
token, self.reset_url_token
)
return HttpResponseRedirect(redirect_url)
오랜만에 다같이 모여서 모각코를 진행하니 내가 코딩한 내용들을 정리하고 기록할 수 있어서 좋았다. 다음주에도 일주일 간 있었던 이슈를 정리해보아야겠다.