카테고리 없음

[Django] 비밀번호 변경 링크 토큰 만료 이슈 (set-password url)

이매애진 2024. 9. 3. 21:02
작성자 임혜진
일 시 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

 

 

비슷한 문제 상황
https://stackoverflow.com/questions/66505311/subclass-passwordresetconfirmview-returned-token-becomes-set-password

 

Subclass PasswordResetConfirmView returned token becomes "set-password"

I tried to Subclass my PasswordResetConfirmView for me to create an error message if token is invalid. After some trial and errors, this is what I have come up with based on this Github file: class

stackoverflow.com

 

라이브러리의 원본 코드

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)

 

 

오랜만에 다같이 모여서 모각코를 진행하니 내가 코딩한 내용들을 정리하고 기록할 수 있어서 좋았다. 다음주에도 일주일 간 있었던 이슈를 정리해보아야겠다.