[Django] (점프투장고) 비밀번호 초기화

azzurri21·2022년 1월 17일
0

Django

목록 보기
4/7

점프투장고 3-16 파이보 추가 기능비밀번호 찾기와 변경 기능을 추가하는 과정이다.

본 글에서는 비밀번호 찾기, 초기화를 다룬다.

[완성 소스] : https://github.com/jseop-lim/pybo/tree/3ab8b07f6e8def13a6111f4fbab121ce4453c35b


기획 및 사전 조사

Django auth 앱에 이미 구현된 비밀번호 초기화(reset) 기능을 살펴보고 내 pybo 사이트에 적용할 방법을 생각한다.

참고 사이트

Django 비밀번호 초기화 프로세스 (default)

  1. 사용자는 계정에 등록된 email을 입력하고 버튼을 클릭한다.
    (url: password/reset/ | view: PasswordResetView | template: registration/password_reset_form.html | form: PasswordResetForm)

  2. 이메일이 성공적으로 전송되었다는 창이 나타난다.
    (url: password/reset/done | view: PasswordResetDoneView | template: registration/password_reset_done.html)

  3. 사용자는 이메일을 확인하고 본문의 링크를 클릭한다.
    (이메일 template: registration/password_reset_email.html)

  4. 사용자는 새로 설정할 비밀번호를 2번 입력한다.
    (url: reset/<uidb64>/<token>/ | view: PasswordResetConfirmView | template: registration/password_reset_confirm.html | form: SetPasswordForm)

  5. 비밀번호 초기화가 완료되었다는 창이 나타난다.
    (url: reset/done/ | view: PasswordResetCompleteView| template: registration/password_reset_complete.html)

개발 기획

  • Django의 프로세스와 달리, 이메일과 함께 사용자ID를 입력받는다. 입력 받은 사용자ID가 존재하고, 입력된 email과 해당 사용자에 등록된 email이 일치할 경우에 메일을 발송한다.
  • 프로세스 5단계(password_reset_complete)는 사용하지 않고, 새 비밀번호 입력 후 바로 로그인 화면으로 리다이렉트한다.
  • Django auth 앱에 구현된 view와 form은 최대한 이용하되 필요하면 오버라이딩한다. password_reset_email.html은 그대로 이용한다. 나머지 templete은 직접 작성한다.

SMTP 설정

Django에서 이메일을 보내야하므로 config/settings/base.py (settings.py 분리 이전이라면 config/settings.py)에 이메일 관련 설정을 추가해준다. 또한, 메일을 발송할 gmail 계정에서 IMAP 사용으로 바꿔주고 보안 수준이 낮은 앱의 액세스를 허용해준다.

자세한 방법은 django SMTP gmail 설정 및 테스트를 참고했다.

URL

[mysite\common\urls.py]

from django.urls import path
from django.contrib.auth import views as auth_views
from . import views

# app_name = 'common'

urlpatterns = [
    (... 생략 ...)
    
	# 비밀번호 초기화
	path('password_reset/', views.PasswordResetView.as_view(), name='password_reset'),
	path('password_reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'),
	path('reset/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
]
  • Docs와 동일하게 url과 name을 추가한다.
  • view는 auth 앱의 것을 전부 오버라이딩해서 사용했다. view에 사용될 template, form, 리다이렉트할 url을 수정했다. 자세한 사항은 아래 View 절에서 확인하게 된다.
  • 원래 common 앱에 설정되어 있던 네임스페이스를 제거했다. (후술)

view 클래스를 새로 정의하는 대신 [mysite\common\urls.py]에서 path 함수의 매개변수로

auth_views.PasswordResetDoneView.as_view(template_name = 'common/password_reset_done.html')

처럼 클래스 변수를 바로 할당해주어도 된다.

common 앱의 네임스페이스 제거 이유

네임스페이스를 제거하지 않고 이메일로 받은 링크를 클릭하니 아래와 같은 에러가 발생했다.

에러 원인은 Django auth 앱의 registration/password_reset_email.html이라는 template에서 네임스페이스가 적용되지 않은 password_reset_confirm url로 연결하기 때문이다. 해당 template은 이메일 본문을 나타내는 template이다.

해결책은 아래와 같다.

  1. common/password_reset_email.html을 직접 작성한다.

  2. password_reset_confirm url을 포함하는 common 앱의 네임스페이스를 제거한다.

  3. password_reset_confirm url을 네임스페이스가 없는 앱, 혹은 mysite\config\urls.py의 urlpatterns 리스트 원소로 추가한다.

  4. 비밀번호 초기화 관련된, 네임스페이스가 없는 앱을 새로 만든다.

새로운 파일이나 앱을 추가하기보다 기존 코드의 common 네임스페이스를 전부 지우기로 했다. 매우 비효율적인 작업이지만 정상적으로 작동하는 것을 우선 목표로 하고, registration/password_reset_email.html에 관한 분석이 끝나면 template 파일을 새로 작성하기로 한다.

세 번째 해결책은 아래와 같이 구현한다. mysite\common\urls.py에 있는 password_reset_confirm url은 삭제해도 된다.

[mysite\config\urls.py]

from django.contrib import admin
from django.urls import path, include
from common import views  # 추가
from pybo.views import base_views


urlpatterns = [
    path('admin/', admin.site.urls),
    path('pybo/', include('pybo.urls')),
    path('common/', include('common.urls')),
    path('', base_views.index, name='index'),
    path('reset/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),  # 추가
]

이 방법을 사용하면 common 네임스페이스를 유지할 수 있다.

Template

회원가입 때 사용되는 template(common/signup.html)을 변형하여 비밀번호 찾기 및 초기화 template을 작성했다.

[mysite\templates\common\password_reset.html]

{% extends "base.html" %}
{% block content %}
<div class="container my-3">
    <h4 class="border-bottom pb-2 my-3">비밀번호 찾기</h4>

    <form method="post" class="post-form" action="{% url 'password_reset' %}">
    <!-- action 속성은 생략 가능 -->
        {% csrf_token %}
        {% include "form_errors.html" %}
        <div class="form-group">
            <label for="username">사용자ID</label>
            <input type="text" class="form-control" name="username" id="username"
                   value="{{ form.username.value|default_if_none:'' }}">
        </div>
        <div class="form-group">
            <label for="email">등록한 이메일</label>
            <input type="text" class="form-control" name="email" id="email"
                   value="{{ form.email.value|default_if_none:'' }}">
        </div>
        <button type="submit" class="btn btn-primary">이메일 전송</button>
    </form>
</div>
{% endblock %}

[mysite\templates\common\password_reset_done.html]

{% extends "base.html" %}
{% block content %}
<div class="container my-3">
    <h4 class="border-bottom pb-2 my-3">이메일 전송 완료</h4>

    <a href="{% url 'index' %}" class="btn btn-primary">처음으로</a>
</div>
{% endblock %}

[mysite\templates\common\password_reset_confirm.html]

{% extends "base.html" %}
{% block content %}
<div class="container my-3">
    <h4 class="border-bottom pb-2 my-3">비밀번호 초기화</h4>

    {% if validlink %}
    <form method="post" class="post-form"> <!-- password_reset_confirm으로 action할 필요 없음 -->
        {% csrf_token %}
        {% include "form_errors.html" %}
        <div class="form-group">
            <label for="new_password1">새로운 비밀번호</label>
            <input type="password" class="form-control" name="new_password1" id="new_password1">
        </div>
        <div class="form-group">
            <label for="new_password2">비밀번호 확인</label>
            <input type="password" class="form-control" name="new_password2" id="new_password2">
        </div>
        <button type="submit" class="btn btn-primary">변경하기</button>
    </form>
    {% else %}
    <p>링크가 유효하지 않습니다.</p>
    {% endif %}
</div>
{% endblock %}

Django auth 앱의 registration/password_reset_confirm.html을 참고하여 작성했다. template 변수 validlink는 이미 비밀번호가 변경되었다면 False, 유효한 링크라면 True값을 가진다.

common/password_reset_confirm.html에서 form 태그 속성으로 action="{% url 'password_reset_confirm' %}" 을 추가하면 아래와 같은 에러가 발생한다.

View

[mysite\common\views.py]

(... 생략 ...)
from django.contrib.auth import views as auth_views
from django.urls import reverse_lazy
from common.forms import UserForm, PasswordResetForm


class PasswordResetView(auth_views.PasswordResetView):
    """
    비밀번호 초기화 - 사용자ID, email 입력
    """
    template_name = 'common/password_reset.html'
    # success_url = reverse_lazy('password_reset_done')
    form_class = PasswordResetForm
    # email_template_name = 'common/password_reset_email.html'


class PasswordResetDoneView(auth_views.PasswordResetDoneView):
    """
    비밀번호 초기화 - 메일 전송 완료
    """
    template_name = 'common/password_reset_done.html'


class PasswordResetConfirmView(auth_views.PasswordResetConfirmView):
    """
    비밀번호 초기화 - 새로운 비밀번호 입력
    """
    template_name = 'common/password_reset_confirm.html'
    success_url = reverse_lazy('login')

비밀번호 초기화와 관련되어 Django auth 앱에 정의된 3개의 view 클래스를 상속하는 같은 이름의 자식 클래스를 정의한다. Django auth 앱의 view.py는 auth_views라는 이름으로 불러왔다.

  • template을 직접 작성했으므로 template_name에 각 파일의 경로를 할당해준다.

  • success_url에는 작업이 완료된 후 리다이렉트하는 url을 적어준다. 클래스 형태로 view를 정의했으므로 reverse_lazy 함수를 사용한다. 위에서 설명했듯이, 새로운 비밀번호 입력 후에는 로그인 화면으로 전환하도록 설정한다.

    PasswordResetView의 success_url에는 'password_reset_done'이 디폴트로 할당되어 있다. 만약 common 앱에 네임스페이스가 설정되어 있다면 success_url = reverse_lazy('common:password_reset_done')을 적어주어야 한다.

  • PasswordResetView는 원래 Django 구현과 달리 사용자ID를 추가로 입력 받기 때문에, 내가 직접 정의한 form을 사용한다. 따라서 form_class에 이를 할당해준다. (다음 Form 절에서 자세히 다루겠지만, form의 이름은 PasswordResetForm으로 Django가 정의한 것과 같은 이름이다.)

  • 사용자가 받는 이메일의 template파일은 email_template_name로 설정할 수 있다. 'registration/password_reset_email.html'가 디폴트로 할당된다.

    만약 mysite\templates\common\password_reset_email.html에 template 파일을 직접 만들어 저장했다면 주석처럼 'common/password_reset_email.html'을 할당한다.

Form

[mysite\common\forms.py]

from django.core.exceptions import ValidationError
import django.contrib.auth.forms as auth_forms
from django.contrib.auth.models import User

(... 생략 ...)


class PasswordResetForm(auth_forms.PasswordResetForm):
    username = auth_forms.UsernameField(label="사용자ID")  # CharField 대신 사용

    # validation 절차:
    # 1. username에 대응하는 User 인스턴스의 존재성 확인
    # 2. username에 대응하는 email과 입력받은 email이 동일한지 확인

    def clean_username(self):
        data = self.cleaned_data['username']
        if not User.objects.filter(username=data).exists():
            raise ValidationError("해당 사용자ID가 존재하지 않습니다.")

        return data

    def clean(self):
        cleaned_data = super().clean()
        username = cleaned_data.get("username")
        email = cleaned_data.get("email")

        if username and email:
            if User.objects.get(username=username).email != email:
                raise ValidationError("사용자의 이메일 주소가 일치하지 않습니다")

    def get_users(self, email=''):
        active_users = User.objects.filter(**{
            'username__iexact': self.cleaned_data["username"],
            'is_active': True,
        })
        return (
            u for u in active_users
            if u.has_usable_password()
        )

Django 기본 auth 앱과 달리, 사용자ID를 추가로 입력받으므로 PasswordResetForm을 직접 정의하여 사용한다. 더불어 사용자와 이메일에 대한 유효성 검사도 달라지므로 메서드를 오버라이딩한다.

django\contrib\auth\forms.py를 보니, LoginView에 사용되는 AuthenticationForm의 username 속성이 UsernameField라는 자료형으로 정의되어 있었다. 그래서 같은 클래스로 PasswordResetForm의 username 필드를 정의했으며 작동에 문제가 없었다.

유효성 검사

(유효성 검사 절차에 관한 자세한 설명은 다른 게시물에 정리되어 있다.)

주석에 적은 유효성 검사를 진행하기 위해 clean_username() 메서드를 추가하고 clean() 메서드를 오버라이딩했다.

clean_username() 메서드에서는 사용자ID의 존재성을 확인하기 위해 Queryset의 exists() 메서드를 사용했다. stackflow를 참고했다.

clean() 메서드의 전체적인 틀은 Django Docs 예시를 참고했다.

get_users()

(PasswordResetForm의 코드는 Django github[django 설치 경로\django\contrib\auth\form.py]에서 확인할 수 있다.)

[django 설치 경로\django\contrib\auth\form.py]

class PasswordResetForm(forms.Form):
    email = forms.EmailField(
        label=_("Email"),
        max_length=254,
        widget=forms.EmailInput(attrs={'autocomplete': 'email'})
    )

	(... 생략 ...)

    def get_users(self, email):
        """Given an email, return matching user(s) who should receive a reset.

        This allows subclasses to more easily customize the default policies
        that prevent inactive users and users with unusable passwords from
        resetting their password.
        """
        email_field_name = UserModel.get_email_field_name()
        active_users = UserModel._default_manager.filter(**{
            '%s__iexact' % email_field_name: email,
            'is_active': True,
        })
        return (
            u for u in active_users
            if u.has_usable_password() and
            _unicode_ci_compare(email, getattr(u, email_field_name))
        )

    def save(self, domain_override=None,
             subject_template_name='registration/password_reset_subject.txt',
             email_template_name='registration/password_reset_email.html',
             use_https=False, token_generator=default_token_generator,
             from_email=None, request=None, html_email_template_name=None,
             extra_email_context=None):
        """
        Generate a one-use only link for resetting password and send it to the
        user.
        """
        email = self.cleaned_data["email"]
        (... 생략 ...)

        for user in self.get_users(email):
            (... 생략 ...)

Django auth 앱은 기본적으로 비밀번호 초기화 시 오직 이메일만 입력받는다. email을 매개변수로 받는 get_users() 메서드는 입력된 이메일에 대응하는 모든 사용자를 geterator 형태로 반환한다. PasswordResetForm의 save() 메서드는 get_users()에서 반환한 사용자들에게 전부 비밀번호 초기화 링크가 포함된 이메일을 전송한다.

내 사이트는 User 모델의 email 필드에 unique = True 속성이 적용되어 있지 않아 여러 사용자가 같은 이메일을 공유할 수 있다. 그래서 사용자ID를 추가로 입력받도록 설계했다. 그렇다면 입력받은 사용자ID와 PasswordResetForm의 이메일 전송 대상 사용자를 연결해주어야 한다. 따라서 get_users() 메서드를 오버라이딩하여 입력한 사용자ID에 대응하는 User 인스턴스를 반환한다.

User.objects.filter()에 전달하는 딕셔너리 원소로 'username__iexact': self.cleaned_data["username"] 대신 'username__iexact': self.username을 전달하면 안된다. self.username은 Form 인스턴스가 아닌 클래스 변수이기 때문이다.

Django Form은 기본적으로 유효성 검사를 통과한 필드 값을 cleaned_data 딕셔너리에 필드이름: 값 형태로 저장한다.

User의 is_active 속성은 휴면 상태가 아닌지를 의미하는 것으로 보인다. 현재 사이트에는 적용되지 않지만, 훗날 확장 가능성을 고려하여 get_users() 구현 시 남겨둔다.

profile
파이썬 백엔드 개발자

0개의 댓글