사용자인증(3)

 

1. 이메일인증

회원가입을 할 때 이메일이 중복이 되지 않는다면 조건없이 가입이 되었습니다. 간혹 실수 혹은 고의로 이메일 주소를 실존하지 않는 이메일로 입력하거나 누군가 다른 사람의 이메일로 가입하는 경우도 있을 수 있습니다. 일반적으로 가입 직후 이메일을 회원의 아이디로 사용하는 경우 가입된 이메일로 인증이메일을 보내고 인증이메일의 인증하기 버튼을 클릭할 경우 인증이 되고 아이디를 사용할 수 있도록 변경해줍니다. 이메일인증은 장고에서 제공하지 않는 기능이지만 내장된 이메일 전송기능을 활용해서 비교적 간단하게 이메일 인증기능을 구현해봅니다.

이메일인증 기능을 구현하기 전에 어떤 순서를 통해 인증이 되는지 설계를 하고 각 단계를 하나씩 구현해 나가도록 하겠습니다.

  1. 가입즉시 인증이메일 보내기
  2. 인증토큰 생성
  3. 사용자인증 페이지로 이동할 수 있는 링크를 포함한 인증이메일 발송
  4. 인증이메일에서 인증하기 링크 클릭 후 사용자인증 페이지로 이동
  5. 사용자인증 페이지에서 url에 포함된 사용자id와 인증토큰을 비교해서 인증
  6. 정상적인 사용자인 경우 is_active 를 True 로 변경 후 인증완료 화면 표시
  7. 비정상적인 사용자인 경우 인증실패 화면 표시하여 재인증 가능한 링크 제공
  8. 인증되지 않은 사용자의 경우 인증이메일 재발송 가능하도록 링크 제공

이메일보내기

이메일 인증 기능을 보내려면 우선 장고에서 이메일을 보낼 수 있어야 합니다. 이메일을 보내기 위해서 설정파일에 몇가지 설정을 하고, 이메일 템플릿을 작성만 하면 됩니다.

gmail 기준으로 설정하는 방법을 설명드립니다. gmail 이외에도 대부분의 이메일 서비스에서 아래와 같은 설정내용을 제공하니 잘 찾아보시기 바랍니다.

# minitutorial/settings.py

# 생략

EMAIL_HOST = 'smtp.gmail.com'
EMAIL_PORT = 587
EMAIL_HOST_USER = 'awesome@gmail.com'
EMAIL_HOST_PASSWORD = '7h1515myp455w0rd'
EMAIL_USE_TLS = True

gmail을 사용하신다면 다른 부분은 모두 동일하고 EMAIL_HOST_USER, EMAIL_HOST_PASSWORD 만 자신의 계정의 맞는 값으로 설정하세요.

(534, b'5.7.14 <https://accounts.google.com/signin/continue?sarp=1&scc=1&plt=AKgnsbvz\n5.7.14 Ss4-Ru3haJUuYoQ3nIS14Mkq0Coxa7Tq0uR1kRepwx–S7Vt_uiyHZNq9UExqZb5w5Yjvk\n5.7.14 jKWrk_N2A3c9SIwf9f6Xb44Tluq2ygOmBBGUXW8nb_MV60BO0rWStZkKG9ti830JXJwfhw\n5.7.14 TXT7CybJO0k-oRO3pER74z6Y5cUctpv50uLkb-R7ViW2w93tu2FVp5STDlpwg_QMy3Wl5H\n5.7.14 _kaLsuezAqNG5-w-Oqg0ZPE8OTxgs1PA0_cxeORoSQseNivNXQ> Please log in via\n5.7.14 your web browser and then try again.\n5.7.14 Learn more at\n5.7.14 https://support.google.com/mail/answer/78754 d21sm2862209pgv.37 - gsmtp')

이런 에러가 발생했다면 gmail 설정 에서 보안 수준이 낮은 앱 허용 을 활성화시켜주세요. google 은 google 앱이 아닌 앱에서 로그인을 시도할 경우 차단하는 것이 기본설정입니다.

shell 커맨드에서 이메일이 잘 발송되는 지 테스트해봅니다.

>>> from minitutorial import settings
>>> from user.models import User
>>> user = User.objects.get(email='swarf00@gmail.com')          # 회원가입된 사용자
>>> user.email_user('test', 'this is a test', from_email=settings.EMAIL_HOST_USER)  # 제목, 본문

확인해보시면 여기까지 문제없이 이메일이 발송되고 몇 초 이내에 이메일을 받아보실 수 있을 겁니다. 그럼 실제로 뷰에 관련 내용을 구현해보도록 합니다.

이메일인증, 재발송 구현하기

auth 프레임워크의 모델 백엔드는 is_activeTrue 인 사용자만 정상적인 사용자로 인증합니다. 기존에는 이메일인증 기능이 없었기 때문에 가입과 동시에 is_active 값이 True 로 저장되도록 했었는데 이메일인증이 되기 전까지 False 로 저장을 하도록 수정합니다.

# user/models.py

# 생략

class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(verbose_name=_('email address'), unique=True, blank=False)
    name = models.CharField(_('name'), max_length=30, blank=True)
    is_staff = models.BooleanField(
        _('staff status'),
        default=False,
        help_text=_('Designates whether the user can log into this admin site.'),
    )
    is_active = models.BooleanField(
        _('active'),
        default=False,                 # 기본값을 False 로 변경
        help_text=_(
            'Designates whether this user should be treated as active. '
            'Unselect this instead of deleting accounts.'
        ),
    )

# 생략

가입 즉시 이메일인증 메일을 발송하도록 UserRegistrationViewform_valid 메소드를 오버라이드 합니다. form_valid 메소드는 폼객체의 필드값들이 유효성 검증을 통과할 경우 호출되는데 각 필드의 값을 데이터베이스에 저장하는 역할을 합니다. 즉 form_valid 메소드가 실행 후 이메일인증 메일을 발송하면 됩니다.

# user/views.py

from django.contrib import messages
from django.contrib.auth.tokens import default_token_generator

from minitutorial import settings

# 생략

class UserRegistrationView(CreateView):
    model = get_user_model()
    form_class = UserRegistrationForm
    success_url = '/user/login/'
    verify_url = '/user/verify/'
    token_generator = default_token_generator

    def form_valid(self, form):
        response = super().form_valid(form)
        if form.instance:
            self.send_verification_email(form.instance)
        return response

    def send_verification_email(self, user):
        token = self.token_generator.make_token(user)
        user.email_user('회원가입을 축하드립니다.', '다음 주소로 이동하셔서 인증하세요. {}'.format(self.build_verification_link(user, token)), from_email=settings.EMAIL_HOST_USER)
        messages.info(self.request, '회원가입을 축하드립니다. 가입하신 이메일주소로 인증메일을 발송했으니 확인 후 인증해주세요.')

    def build_verification_link(self, user, token):
        return '{}/user/{}/verify/{}/'.format(self.request.META.get('HTTP_ORIGIN'), user.pk, token)

# 생략

UserRegistrationFormModelForm 을 상속받은 클래스인데 form_valid 메소드를 호출하면 데이터베이스에 저장(Form.save())을 하고 저장된 데이터를 폼객체의 instance 변수에 저장을 합니다. 그래서 token 을 생성할 때 이 form.instance 를 이용하도록 했습니다. (장고의 내장 뷰에서는 폼클래스에서 save() 메소드 호출 직후 처리를 하기도 합니다.) default_token_generator 는 사용자 데이터를 가지고 해시데이터를 만들어주는 객체인데 이것을 이용해서 생성된 사용자 고유의 토큰을 생성합니다. 생성된 토큰사용자id(pk) 값을 인증페이지의 url에 포함하여 어떤 사용자의 토큰인지 url만 보고 확인할 수 있도록 합니다.
이제 실제로 가입하기를 해보면 이메일이 발송되고 이메일 내용에 다음 주소로 이동하셔서 인증하세요. http://localhost:8000/user/9/verify/524-08f72f288f9f86d04084/ 와 같이 매번 다른 토큰값(524-08f72f288f9f86d04084)을 인증페이지의 url에 포함시킵니다. 물론 이 링크를 클릭할 경우 아직은 이동할 페이지가 없다는 오류가 발생됩니다.

인증페이지 생성

이제 인증이메일의 링크를 클릭했을 때 이동할 인증페이지를 만들어야 합니다. 먼저 인증뷰를 생성합니다. 인증뷰는 url 사용자id 값과 token 을 가지고 해당 사용자의 정상적인 token 값인지 확인 후 정상적인 경우 로그인페이지(또는 웰컴페이지)로 이동시키고 인증이 완료되었다는 메시지를 출력시켜주면 됩니다. 정상적이지 않은 경우 인증실패 메시지와 인증메일을 재발송할 수 있도록 링크를 추가하면 됩니다. 어차피 나중에 로그인 페이지에서 인증이메일 재발송 기능을 추가할 예정이니 인증실패시 로그인페이지로 이동할 수 있는 링크를 제공해주도록 하겠습니다.

# user/views.py

from django.http import HttpResponseRedirect
from django.views.generic.base import TemplateView

# 생략

class UserVerificationView(TemplateView):

    model = get_user_model()
    redirect_url = '/user/login/'
    token_generator = default_token_generator

    def get(self, request, *args, **kwargs):
        if self.is_valid_token(**kwargs):
            messages.info(request, '인증이 완료되었습니다.')
        else:
            messages.error(request, '인증이 실패되었습니다.')
        return HttpResponseRedirect(self.redirect_url)   # 인증 성공여부와 상관없이 무조건 로그인 페이지로 이동

    def is_valid_token(self, **kwargs):
        pk = kwargs.get('pk')
        token = kwargs.get('tonen')
        user = self.model.objects.get(pk=pk)
        is_valid = self.token_generator.check_token(user, token)
        if is_valid:
            user.is_active = True
            user.save()     # 데이터가 변경되면 반드시 save() 메소드 호출
        return is_valid

# 생략

토큰의 유효성 확인도 default_token_generator 를 이용합니다. 유효한 토큰일 경우 사용자의 is_activeTrue 로 변경시키고 저장해야 합니다. 주의할 것은 인증에 실패했다고 is_activeFalse 로 변경시키면 안됩니다. 혹시나 악의적인 목적으로 url 을 난수로 대입할 경우 정상적인 사용자id와 충돌이 생겨 인증상태가 변경될 수도 있으니 인증이 실패할 경우는 그대로 무시하고, 다만 실패되었다는 메시지만 출력해주는 것으로 확인시켜주면 됩니다.

인증이 성공할 경우 곧바로 인증세션정보를 생성해서(django.contrib.auth.login()) 로그인된 것으로 처리한다면 사용자 입장에서는 좀 편리할 수 있으나 그만큼 보안강도가 약해지는 것이기 때문에 별도로 로그인을 하도록 유도하는 것이 보안에 좀 더 좋은 방법입니다.

인증뷰의 핸들러를 호출할 수 있도록 urlpatterns 에 추가합니다. url 에는 사용자id(pk) 와 토큰(token)이 포함되도록 선언하면 됩니다.

# minitutorial/urls.py

from user.views import UserRegistrationView, UserLoginView, UserVerificationView

urlpatterns = [
    path('hello/<to>', hello), 

    path('article/', ArticleListView.as_view(), name='article-list'),
    path('article/create/', ArticleCreateUpdateView.as_view()),
    path('article/<article_id>/', ArticleDetailView.as_view()),
    path('article/<article_id>/update/', ArticleCreateUpdateView.as_view()),

    path('user/create/', UserRegistrationView.as_view()),
    path('user/<pk>/verify/<token>/', UserVerificationView.as_view()),
    path('user/login/', UserLoginView.as_view()),

    path('admin/', admin.site.urls),
]

이제 정상적으로 인증이 되는지 이메일의 링크를 클릭하고 이동된 로그인 페이지에서 로그인을 해봅니다. 정상적으로 로그인이 되었다면 한가지 작업을 더 해주면 좋을 것 같습니다.

이메일 템플릿 생성

이메일 본문에 일반 텍스트로만 전달하다보니 이메일의 내용이 신뢰가 가지 않고 일부 이메일 클라이언트에서는 링크주소를 클릭할 경우 아무런 반응이 없을 때도 있습니다. 이메일도 미리 만들어놓은 템플릿에 사용자별로 인증코드만 수정해서 보내면 좀 더 좋을 것 같습니다.
이메일에는 css가 적용되지 않는 경우가 많으니 반드시 태그안에 inline style 속성으로 디자인해야 합니다. 뉴스레터 디자인을 위한 여러 종류에 에디터가 있으나 저는 grapejs 를 통해 디자인을 했습니다.

<!-- user/templates/user/registration_verification.html -->

<table style="box-sizing: border-box; min-height: 150px; padding-top: 5px; padding-right: 5px; padding-bottom: 5px; padding-left: 5px; width: 100%; height: 100%; background-color: rgb(234, 236, 237); font-family: Arial Black, Gadget, sans-serif;" width="100%" height="100%" bgcolor="rgb(234, 236, 237)">
  <tbody style="box-sizing: border-box;">
    <tr style="box-sizing: border-box; vertical-align: top;" valign="top">
      <td style="box-sizing: border-box;">
        <table style="box-sizing: border-box; font-family: Helvetica, serif; min-height: 150px; padding-top: 5px; padding-right: 5px; padding-bottom: 5px; padding-left: 5px; margin-top: auto; margin-right: auto; margin-bottom: auto; margin-left: auto; height: 0px; width: 90%; max-width: 550px;" width="90%" height="0">
          <tbody style="box-sizing: border-box;">
            <tr style="box-sizing: border-box;">
              <td style="box-sizing: border-box; vertical-align: top; font-size: medium; padding-bottom: 50px;" valign="top">
                <table style="box-sizing: border-box; min-height: 150px; padding-top: 5px; padding-right: 5px; padding-bottom: 5px; padding-left: 5px; margin-bottom: 20px; height: 0px;" height="0">
                  <tbody style="box-sizing: border-box;">
                    <tr style="box-sizing: border-box;">
                      <td style="box-sizing: border-box; background-color: rgb(255, 255, 255); overflow-x: hidden; overflow-y: hidden; border-top-left-radius: 3px; border-top-right-radius: 3px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; padding-top: 0px; padding-right: 0px; padding-bottom: 0px; padding-left: 0px; text-align: center;" bgcolor="rgb(255, 255, 255)" align="center">
                        <table style="box-sizing: border-box; width: 100%; min-height: 150px; padding-top: 5px; padding-right: 5px; padding-bottom: 5px; padding-left: 5px; height: 0px; margin-top: 0px; margin-right: 0px; margin-bottom: 0px; margin-left: 0px; border-collapse: collapse;" width="100%" height="0">
                          <tbody style="box-sizing: border-box;">
                            <tr style="box-sizing: border-box;">
                              <td style="box-sizing: border-box; font-size: 13px; line-height: 20px; color: rgb(111, 119, 125); padding-top: 10px; padding-right: 20px; padding-bottom: 0px; padding-left: 20px; vertical-align: top;" valign="top">
                                <h1 style="box-sizing: border-box; font-size: 25px; font-weight: 300; color: rgb(68, 68, 68);">
                                  <span style="box-sizing: border-box; font-family: Arial,Helvetica,sans-serif;">가입을 환영합니다.!!</span>
                                </h1>
                                <p style="box-sizing: border-box;">
                                  <span style="box-sizing: border-box; font-family: Arial,Helvetica,sans-serif;">회원님이 가입하신 것이 맞다면 아래 "인증하기" 버튼을 눌러서 인증해주세요. 가입하신 적이 없을 경우 인증하기를 누르지 마시고 무시하세요.</span>
                                </p>
                                <table style="box-sizing: border-box; margin-top: 0px; margin-right: auto; margin-bottom: 10px; margin-left: auto; padding-top: 5px; padding-right: 5px; padding-bottom: 5px; padding-left: 5px; width: 100%;" width="100%">
                                  <tbody style="box-sizing: border-box;">
                                    <tr style="box-sizing: border-box;">
                                      <td style="box-sizing: border-box; padding-top: 20px; padding-right: 0px; padding-bottom: 20px; padding-left: 0px; text-align: center;" align="center">
                                        <a href="{{ url }}" class="button" style="box-sizing: border-box; font-size: 16px; padding-top: 10px; padding-right: 20px; padding-bottom: 10px; padding-left: 20px; background-color: rgb(217, 131, 166); color: rgb(255, 255, 255); text-align: center; border-top-left-radius: 3px; border-top-right-radius: 3px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; font-family: Arial, Helvetica, sans-serif; font-weight: 500; text-decoration: underline;">인증하기</a>
                                      </td>
                                    </tr>
                                  </tbody>
                                </table>
                              </td>
                            </tr>
                          </tbody>
                        </table>
                      </td>
                    </tr>
                  </tbody>
                </table>
              </td>
            </tr>
          </tbody>
        </table>
      </td>
    </tr>
  </tbody>
</table>

이메일의 템플릿은 일반 html 의 head 와 body 없이 body 안에 보여줘야 할 부분만 있으면 됩니다. grapejs 에서는 알아서 잘 만들어주기 때문에 그대로 복사해왔습니다. 다만 인증하기 버튼의 url 부분만 {{ url }} 로 변경해서 렌더링할 때마다 원하는 값으로 링크주소가 변경될 수 있도록 수정했습니다.

뷰에서는 템플릿을 렌더링해서 사용자마다 인증코드를 적용해서 보여주도록 수정해야 합니다.

# user/views.py

from django.shortcuts import render

# 생략

class UserRegistrationView(CreateView):
    model = get_user_model()
    form_class = UserRegistrationForm
    success_url = '/user/login/'
    verify_url = '/user/verify/'
    email_template_name = 'user/email/registration_verification.html'
    token_generator = default_token_generator

    def form_valid(self, form):
        response = super().form_valid(form)
        if form.instance:
            self.send_verification_email(form.instance)
        return response

    def send_verification_email(self, user):
        token = self.token_generator.make_token(user)
        url = self.build_verification_link(user, token)
        subject = '회원가입을 축하드립니다.'
        message = '다음 주소로 이동하셔서 인증하세요. {}'.format(url)
        html_message = render(self.request, self.email_template_name, {'url': url}).content.decode('utf-8')
        user.email_user(subject, message, settings.EMAIL_HOST_USER, html_message=html_message)
        messages.info(self.request, '회원가입을 축하드립니다. 가입하신 이메일주소로 인증메일을 발송했으니 확인 후 인증해주세요.')

    def build_verification_link(self, user, token):
        return '{}/user/{}/verify/{}/'.format(self.request.META.get('HTTP_ORIGIN'), user.pk, token)

# 생략

유심히 봐야 할 부분은 render 함수 호출부분인데, render 함수는 HttpResponse 객체를 반환합니다. HttpResponse 객체의 content 속성에 렌더링된 메시지가 저장되어 있는데 http로 전송할 수 있도록 byte 로 인코딩되어 있습니다. email_user 메소드에 전달할 때 반드시 utf-8 로 디코딩을 해줘야 합니다. email_user 메소드를 호출할 때 기존의 텍스트 메시지와 html 메시지 둘다 전달한 이유는 일부 이메일 클라이언트에서는 html 형식의 이메일을 지원하지 않을 수 있어서 html 메시지를 보여줄 수 없는 이메일 클라이언트에게 기본적으로 보여줄 내용으로 텍스트 메시지를 전달하는 것이 좋습니다.

django.template.loader.render_to_string 함수를 이용하면 곧바로 렌더링된 utf-8 문자열을 출력합니다.

인증이메일 재발송

어떠한 이유로 인증이메일을 삭제해서 복구가 불가능하거나 인증이메일에 문제가 생겼을 경우가 간혹 있을 수 있습니다. 그럴 경우 인증이메일을 재전송해서 이메일인증을 할 수 있도록 해야 합니다. 로그인 화면에서 인증메일 발송 링크를 추가해서 로그인폼을 재활용하는 방법도 있겠지만 사용자들에게는 기능이 모호할 수 있으니 따로 페이지를 만들어 제공하겠습니다.
언제나 그렇듯 뷰 먼저 생성을 합니다.

# user/views.py

from django.views.generic import CreateView, FormView

# 생략

class ResendVerifyEmailView(FormView):
    model = get_user_model()
    form_class = VerificationEmailForm
    success_url = '/user/login/'
    template_name = 'user/resend_verify_email.html'
    email_template_name = 'user/email/registration_verification.html'
    token_generator = default_token_generator

    def send_verification_email(self, user):
        token = self.token_generator.make_token(user)
        url = self.build_verification_link(user, token)
        subject = '회원가입을 축하드립니다.'
        message = '다음 주소로 이동하셔서 인증하세요. {}'.format(url)
        html_message = render(self.request, self.email_template_name, {'url': url}).content.decode('utf-8')
        user.email_user(subject, message, from_email=settings.EMAIL_HOST_USER, html_message=html_message)
        messages.info(self.request, '회원가입을 축하드립니다. 가입하신 이메일주소로 인증메일을 발송했으니 확인 후 인증해주세요.')
    
    def build_verification_link(self, user, token):
        return '{}/user/{}/verify/{}/'.format(self.request.META.get('HTTP_ORIGIN'), user.pk, token)

    def form_valid(self, form):
        email = form.cleaned_data['email']
        try:
            user = self.model.objects.get(email=email)
        except self.model.DoesNotExist:
            messages.error(self.request, '알 수 없는 사용자 입니다.')
        else:
            self.send_verification_email(user)
        return super().form_valid(form)

인증이메일 발송기능을 회원가입뷰와 동일하게 만들고 보니 나중에 subject가 변경된다거나 message가 변경될 때 동일한 작업을 두번 이상 해줘야 할 불편함이 앞섭니다. 다른 작업보다 먼저 중복된 코드를 한쪽에 몰아주는 리팩토링을 해주고 싶은데 중복되는 메소드와 클래스변수를 mixin 에 선언하고, 회원가입뷰와 인증이메일 재발송하는 뷰에서는 해당 mixin 을 추가하도록 하겠습니다.

# user/mixins.py

from django.contrib import messages
from django.contrib.auth.tokens import default_token_generator
from django.shortcuts import render

from minitutorial import settings


class VerifyEmailMixin:
    email_template_name = 'user/email/registration_verification.html'
    token_generator = default_token_generator

    def send_verification_email(self, user):
        token = self.token_generator.make_token(user)
        url = self.build_verification_link(user, token)
        subject = '회원가입을 축하드립니다.'
        message = '다음 주소로 이동하셔서 인증하세요. {}'.format(url)
        html_message = render(self.request, self.email_template_name, {'url': url}).content.decode('utf-8')
        user.email_user(subject, message, from_email=settings.EMAIL_HOST_USER,html_message=html_message)
        messages.info(self.request, '회원가입을 축하드립니다. 가입하신 이메일주소로 인증메일을 발송했으니 확인 후 인증해주세요.')

    def build_verification_link(self, user, token):
        return '{}/user/{}/verify/{}/'.format(self.request.META.get('HTTP_ORIGIN'), user.pk, token)
# user/views.py

# 생략

class UserRegistrationView(VerifyEmailMixin, CreateView):
    model = get_user_model()
    form_class = UserRegistrationForm
    success_url = '/user/login/'
    verify_url = '/user/verify/'

    def form_valid(self, form):
        response = super().form_valid(form)
        if form.instance:
            self.send_verification_email(form.instance)
        return response

class ResendVerifyEmailView(VerifyEmailMixin, FormView):
    model = get_user_model()
    form_class = VerificationEmailForm
    success_url = '/user/login/'
    template_name = 'user/resend_verify_email.html'

# 생략

이제 편안해졌으니 ResendVerifyEmailView 을 다시 살펴보면 form_classVerificationEmailForm 을 정의했습니다. 장고에서 제공하는 폼클래스가 없어서 새로 생성해야 하는 폼입니다.

# user/forms.py

# 생략

class VerificationEmailForm(forms.Form):
    email = EmailField(widget=forms.EmailInput(attrs={'autofocus': True}), validators=(EmailField.default_validators + [RegisteredEmailValidator()]))

# 생략

VerificationEmailFormemail 필드 하나만 가지고 있고, 추가로 유효성 검증 필터를 하나 더 추가했습니다. 이미 인증된 이메일이나, 가입된 적 없는 이메일이 입력된 경우 에러를 발생시키는 기능을 합니다. 에러메시지를 필드에 표시하기 위해 뷰가 아닌 필드에서 유효성을 검증하도록 했습니다.
유효성 검증필터는 EmailField 의 기본 필터에 추가하기 위해서 RegisteredEmailValidator() 인스턴스를 default_validators 리스트에 추가했습니다.

# user/validators.py

from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError


class RegisteredEmailValidator:
    user_model = get_user_model()
    code = 'invalid'

    def __call__(self, email):
        try:
            user = self.user_model.objects.get(email=email)
        except self.user_model.DoesNotExist:
            raise ValidationError('가입되지 않은 이메일입니다.', code=self.code)
        else:
            if user.is_active:
                raise ValidationError('이미 인증되어 있습니다.', code=self.code)

        return

필드의 유효성 검증 필터는 반드시 __call__ 메소드를 오버라이드 해줘야 합니다. 이 메소드는 인스턴스를 invoke 연산자(소괄호)로 호출시 실행하는 함수입니다. 폼의 필드는 유효성을 검증할 때 필드에 정의된 default_validators 리스트의 각 원소들을 입력된 값을 전달하여 함수처럼 호출합니다. 그렇기 때문에 validators=(EmailField.default_validators + [RegisteredEmailValidator()]) 라고 필터에 인스턴스를 추가했지만 내부적으로 for validator in default_validators: validator(email) 이런 식으로 호출이 가능합니다.

원래 클래스의 인스턴스는 invoke 연산자로 호출이 불가능합니다. 파이썬에서는 함수도 객체(인스턴스)인데, __call__ 메소드가 구현되어 있다고 생각하시면 됩니다.

이제 ResendVerifyEmailView 클래스에 폼의 유효성이 검증된 후 인증이메일을 발송하도록 구현하면 됩니다.
ResendVerifyEmailView 에서는 폼클래스가 UserRegistrationForm 과는 다르게 모델폼이 아니기 때문에 데이터베이스에 저장할 것이 없고 단지 success_url 로 이동하는 역할만 합니다. 그래서 부모클래스의 메소드가 실행될 때까지 기다리지 않고 이메일을 전송하도록 했습니다.
폼객체는 유효성검증 작업이 끝나면 cleaned_data 라는 인스턴스변수에 각 필드 이름으로 사용자가 입력한 값들을 저장합니다. 즉 사용자가 입력한 이메일은 form.cleaned_data['email'] 에서 확인할 수 있습니다. 이것으로 확인된 이메일을 통해 인증이메일을 보내도록 기능을 추가합니다.

# user/views.py

# 생략

class ResendVerifyEmailView(VerifyEmailMixin, FormView):
    model = get_user_model()
    form_class = VerificationEmailForm
    success_url = '/user/login/'
    template_name = 'user/resend_verify_email.html'

    def form_valid(self, form):
        email = form.cleaned_data['email']
        try:
            user = self.model.objects.get(email=email)
        except self.model.DoesNotExist:
            messages.error(self.request, '알 수 없는 사용자 입니다.')
        else:
            self.send_verification_email(user)
        return super().form_valid(form)

# 생략

특별한 기능은 없고 email 로 가입된 사용자 데이터를 불러와서 그것으로 이메일을 발송하는 기능을 추가했습니다. 템플릿은 login_form.html 템플릿을 복제해서 적절하게 텍스트를 바꿔줍니다.

<!-- user/template/user/resend_verify_email.html -->

<!-- 생략 -->


{% block content %}
<div class="panel panel-default registration">
    <div class="panel-heading">
        인증이메일 보내기
    </div>
    <div class="panel-body">
        <form action="." method="post">
            {% csrf_token %}
            <b class="">재발송할 이메일주소를 입력해주세요.</b>
            {% for field in form %}
                <div class="form-group {% if field.errors|length > 0 %}has-error{%endif %}">
                    <label for="{{ field.id_for_label }}">{{ field.label }}</label>
                    <input name="{{ field.html_name }}" id="{{ field.id_for_lable }}" class="form-control" type="{{ field.field.widget.input_type }}" value="{{ field.value|default_if_none:'' }}">
                    {% for error in field.errors %}
                        <label class="control-label" for="{{ field.id_for_label }}">{{ error }}</label>
                    {% endfor %}
                </div>
            {% endfor %}
            <div class="form-actions">
                <button class="btn btn-primary btn-large" type="submit">인증이메일 보내기</button>
            </div>
        </form>
    </div>
</div>
{% endblock content %}

특별히 폼필드들을 렌더링하기 전에 안내문구를 추가해줬습니다. ResendVerifyEmailView 뷰를 urlpatterns 에 등록하고 화면이 정상적으로 나오는 지 확인해봅니다.

# minitutorial/urls.py

from user.views import UserRegistrationView, UserLoginView, UserVerificationView, ResendVerifyEmailView

urlpatterns = [
    path('hello/<to>', hello), 

    path('article/', ArticleListView.as_view(), name='article-list'),
    path('article/create/', ArticleCreateUpdateView.as_view()),
    path('article/<article_id>/', ArticleDetailView.as_view()),
    path('article/<article_id>/update/', ArticleCreateUpdateView.as_view()),

    path('user/create/', UserRegistrationView.as_view()),
    path('user/<pk>/verify/<token>/', UserVerificationView.as_view()),
    path('user/resend_verify_email/', ResendVerifyEmailView.as_view()),
    path('user/login/', UserLoginView.as_view()),

    path('admin/', admin.site.urls),
]

정상적으로 화면이 출력된다면 이제 마지막 단계인데 로그인 템플릿에 인증이메일 재발송 페이지로 이동하는 링크를 추가합니다. 로그인 버튼 아래 추가하고 로그인 버튼과 거리가 가까우니 조정을 합니다.

<!-- user/templates/resend_verify_email.html -->


<style>
    .registration {
        width: 360px;
        margin: 0 auto;
    }
    p {
        text-align: center;
    }
    label {
        width: 50%;
        text-align: left;
    }
    .control-label {
        width: 100%;
    }
    .registration .form-actions > button {
        width: 100%;
    }
    .link-below-button { margin-top: 10px; text-align: right;}
</style>
<!-- 생략 -->

<div class="form-actions">
    <button class="btn btn-primary btn-large" type="submit">로그인하기</button>
</div>
<a href="/user/resend_verify_email/">
    <div class="link-below-button">인증이메일 재발송</div>
</a>

<!-- 생략 -->

로그인화면으로 이동해서 인증이메일 재발송 링크를 클릭해서 정상적으로 이동하는 지 확인해봅니다. 인증이메일 발송기능은 핵심 기능인 이메일 보내기와 토큰 생성 및 검증 기능을 장고에서 제공하기 때문에 크게 어렵지 않습니다. 특히 사용자에게 이메일을 보내는 기능은 빈번하게 사용되기 때문에 잘 알아두면 여러모로 도움이 됩니다.

2. 로그아웃

로그인까지는 문제없이 잘 동작 합니다. 사용자가 로그인 후 2주까지는 동일 브라우저로 접속할 경우 로그인 상태를 유지합니다. 하지만 공용 pc 를 사용하는 경우 사용자가 아닌 누군가가 로그인된 상태로 pc를 사용할 수 있으니 반드시 로그아웃 기능을 제공해서 사용자가 원하는 시간에 인증을 해제시켜둬야 합니다. 아주 간단한 방법으로 쿠키의 sessionid 값을 삭제하는 방법도 있으나 장고에서는 좀 더 우아한 방법을 제공합니다. 로그아웃은 화면도 필요없고 단지 세션을 정리해주는 기능만 필요합니다. 장고에서는 이 기능을 완벽하게 구현한 뷰를 제공하기 때문에 urlpattern 에 해당 핸들러를 등록해주기만 하면 됩니다.

# minitutorial/urls.py

from django.contrib.auth.views import LogoutView

urlpatterns = [
    path('hello/<to>', hello), 

    path('article/', ArticleListView.as_view(), name='article-list'),
    path('article/create/', ArticleCreateUpdateView.as_view()),
    path('article/<article_id>/', ArticleDetailView.as_view()),
    path('article/<article_id>/update/', ArticleCreateUpdateView.as_view()),

    path('user/create/', UserRegistrationView.as_view()),
    path('user/<pk>/verify/<token>/', UserVerificationView.as_view()),
    path('user/resend_verify_email/', ResendVerifyEmailView.as_view()),
    path('user/login/', UserLoginView.as_view()),
    path('user/logout/', LogoutView.as_view()),

    path('admin/', admin.site.urls),
]

auth 프레임워크에서 제공하는 LogoutView 클래스에는 여러분이 원하는 모든 기능이 구현되어 있습니다. 단지 로그아웃버튼을 어떻게 배치시킬 지와 로그아웃된 이후에 이동할 페이지의 주소를 설정파일의 LOGOUT_REDIRECT_URL 에 정의해주시면 됩니다. 로그아웃버튼은 이미 base.html 파일에 있으니 해당버튼을 로그아웃 뷰로 연결시킬 것입니다. 로그아웃 이동될 화면은 어디로 이동시켜도 좋지만 사용자들이 어떤 작업을 많이 하면 좋겠느냐를 판단하시고 해당 url 을 결정하면 됩니다. 저는 bbs 앱의 기본기능인 게시물목록 보기 화면으로 이동시키겠습니다.

# minitutorial/settings.py

# 생략

LOGOUT_REDIRECT_URL = '/article/'

# 생략
<!-- bbs/templates/base.html -->

<!-- 생략 -->


<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
    <ul class="nav navbar-nav navbar-right">
        <li class="">
            {% if not request.user.is_authenticated %}
            <a href="/user/create/">가입하기</a>
            {% endif %}
        </li>
        <li class="">
            {% if request.user.is_authenticated %}
            <a href="/user/logout/">로그아웃</a>
            {% else %}
            <a href="/user/login/">로그인</a>
            {% endif %}
        </li>
    </ul>
</div>


<!-- 생략 -->

로그아웃은 너무 편하게 기능을 추가해서 어리둥절 할 수 있게지만 이제 로그인이 된 상태라면 언제나 로그아웃을 할 수 있게 되었습니다.

장고의 auth 프레임워크에서 제공하는 뷰들이 어떻게 구현되어 있는 지 살펴보시면 새로운 뷰 개발에 도움이 되실 겁니다.

  1. django.contrib.auth.views.PasswordResetView
  2. django.contrib.auth.views.PasswordResetDoneView
  3. django.contrib.auth.views.PasswordResetConfirmView
  4. django.contrib.auth.views.PasswordResetCompleteView
  5. django.contrib.auth.views.PasswordChangeView
  6. django.contrib.auth.views.PasswordChangeDoneView

복붙말고 따라해보시면 생각보다 어렵지 않게 느껴지실 겁니다. 비밀번호 변경 기능과 비밀번호 초기화 기능은 auth 프레임워크에서 제공하니 이것은 템플릿만 만들면 로그인, 로그아웃 만큼이나 어렵지 않습니다. 한번 직접 만들어보세요.

세상에는 두 종류의 사람들이 있다. 자신이 직접 코딩해낼 수 있다고 생각하는 사람과 해낼 수 없다고 생각하는 사람이다. 물론 두 사람 다 옳다. 그가 생각한대로 되기 때문이다.

swarf00, 확인하지 않을 숙제를 내주면서...