Allauth의 Email Confirmation을 제대로 사용하기

전승윤/부야
18 min readNov 3, 2022

--

어플리케이션이나 웹에서 회원가입 이후 이메일 인증을 하는 단계가 있는 것을 많이 보았을 것이다. 이를 Email Confirmation 단계라고 한다.

Email Confirmation 단계가 있으면 사용자가 그 이메일을 진짜 소유하고 있는지 알 수 있기 때문에 좀 더 보안을 강화할 수 있다.

Django의 회원 관련 기능을 제공하는 라이브러리 allauth 와 이를 rest api로 사용할 수 있게 도와주는 라이브러리인 dj-rest-auth 또한 관련 기능을 제공한다.

나 또한 이 기능을 오늘, 여의도에서 구현을 해놓았지만 아래에 적을 내용처럼 문제를 겪었다.

이번 글에서는 문제를 어떻게 해결했으며, 이메일 인증 단계를 잘 추가할 수 있는지, 그리고 개발자로 하여금 오해를 일으킬 수 있는 관련 settings value를 소개한다.

문제 파악

기존 오늘, 여의도 에서는 배포 전에 미리 이메일 인증 관련 단계를 추가했고, 회원 가입 단계 또한 정상적으로 굴러갔었다.

이메일로 가입 시 가입 마지막 단계에서는 입력된 이메일로 인증 메일을 전송, 그 메일 안의 링크를 통해 인증하는 방식이다.

그러나 이미 인증된 링크를 다시 누를 경우 위와 같이 NoReverseMatch 에러와 함께 500 response가 발생한다.

기존의 방식

# project 폴더/urls.py

from allauth.account.views import ConfirmEmailView

urlpatterns = [
# 내용 생략
path({path to registration url}, include('dj_rest_auth.registration.urls')),
re_path(
r'^account-confirm-email/(?P<key>[-:\\w]+)/$', ConfirmEmailView.as_view(),
name='account_confirm_email',
),
# 내용 생략
]
# project 폴더/settings.py

# ALLAUTH's Account Configuration
# same as ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL
LOGIN_URL = '/yeouido/email/complete/'

# same as ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL
LOGIN_REDIRECT_URL = '/yeouido/email/complete/'

ACCOUNT_CONFIRM_EMAIL_ON_GET = True
ACCOUNT_AUTHENTICATION_METHOD = "email"
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1

# email verification configurations
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.gmail.com' # 메일 호스트 서버
EMAIL_PORT = '587' # gmail과 통신하는 포트

# 아래의 값은 secrets.json 안에 있음
EMAIL_HOST_USER = get_secret("EMAIL") # 발신할 이메일
EMAIL_HOST_PASSWORD = get_secret("EMAIL_PASSWORD") # 발신할 메일의 비밀번호

EMAIL_USE_TLS = True # TLS 보안 방법
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER

위와 같이 settings.pyurls.py 를 설정하면 이메일 인증 단계를 추가할 수 있다.

ACCOUNT_EMAIL_VERIFICATION 에 “mandatory” 를 넣어 회원 가입 시 항상 이메일 인증 단계를 강제한다.

인증 이메일 전송 코드는 allauth에서 보내며 전송된 이메일의 링크를 누르면 allauth의 ConfirmEmailView 가 동작한다.

Allauth의 ConfirmEmailView 를 사용하는 이유

dj-rest-auth가 다음과 같이 설명하기 때문이다.

If you don't want to use API on that step, then just use ConfirmEmailView view from: django-allauth

이메일 인증 API를 사용하고 싶지 않다면 allauth의 ConfirmEmailView를 사용하세요

이메일 인증하는 단계는 rest api 가 아니라 Django의 template를 이용해서 하기 때문에 dj-rest-auth의 추천대로 ConfirmEmailView를 사용했다.

그러나 ConfirmEmailView를 그대로 사용하면 위와 같이 링크를 다시 누를때 오류를 막을 수 없다.

긴급 수정

사실 위의 에러는 다음과 같은 방법으로 해결할 수 있다.

urlpattern에 다음과 같은 줄을 추가하자.

urlpatterns = [
# ...
path('accounts/', include('allauth.urls')),
# ...
]

위와 같이 allauth의 url을 추가한 후 다시 인증된 링크를 클릭하면 아래와 같은 창으로 리다이렉트한다.

물론 좋은 해결법은 아니다. 이 해결법은 단지 500 에러를 해결했을 뿐이며, 아직도 사용자로 하여금 이게 뭐지 라는 반응을 보이게 해버릴 수 있다.

Allauth의 이메일 인증 단계와 관련된 코드

allauth의 이메일 인증 메일 전송은 allauth/account/utils.py 에서 complete_signup() 에서 전송한다.

전송된 메일의 링크는 base_url/account-confirm-email/{key값}/ 이며 메소드는 GET 이다.

위의 urlpattern과 동일한 형태이기 때문에 이 링크를 누르면 지정해둔 대로 allauth의 ConfirmEmailView 가 동작한다.

ConfirmEmailView 는 아래와 같다.

class ConfirmEmailView(TemplateResponseMixin, LogoutFunctionalityMixin, View):

template_name = "account/email_confirm." + app_settings.TEMPLATE_EXTENSION

def get(self, *args, **kwargs):
try:
self.object = self.get_object()
if app_settings.CONFIRM_EMAIL_ON_GET:
return self.post(*args, **kwargs)
except Http404:
self.object = None
ctx = self.get_context_data()
return self.render_to_response(ctx)

def get_object(self, queryset=None):
key = self.kwargs["key"]
emailconfirmation = EmailConfirmationHMAC.from_key(key)
if not emailconfirmation:
if queryset is None:
queryset = self.get_queryset()
try:
emailconfirmation = queryset.get(key=key.lower())
except EmailConfirmation.DoesNotExist:
raise Http404()
return emailconfirmation

링크를 누르면 get 함수가 호출된다.

self.get_object()EmailConfirmationHMAC.from_key(key)를 호출하며 url parameter에 있던 이메일 인증 key를 통해 인증을 시도한다. 만약 잘못된 key이거나 이미 인증된 메일, 또는 이미 사용된 key일 경우에는 EmailConfirmation.DoesNotExist 를 raise 한 후 Django의 Http404()를 raise 한다.

get(self, *args, **kwargs) 에서는 Http404 exception을 catch한 후 self.render_to_response() 를 호출해 클래스 내부 변수인 template_name 의 템플릿을 렌더링 한다.

이 template_name의 템플릿 파일은 아래와 같이 생겼다.

<!-- allauth/template/account/email_confirm.html -->
{% extends "account/base.html" %}

{% load i18n %}
{% load account %}

{% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %}

{% block content %}
<h1>{% trans "Confirm E-mail Address" %}</h1>

{% if confirmation %}

{% user_display confirmation.email_address.user as user_display %}

<p>{% blocktrans with confirmation.email_address.email as email %}Please confirm that <a href="mailto:{{ email }}">{{ email }}</a> is an e-mail address for user {{ user_display }}.{% endblocktrans %}</p>

<form method="post" action="{% url 'account_confirm_email' confirmation.key %}">
{% csrf_token %}
<button type="submit">{% trans 'Confirm' %}</button>
</form>

{% else %}

{% url 'account_email' as email_url %}

<p>{% blocktrans %}This e-mail confirmation link expired or is invalid. Please <a href="{{ email_url }}">issue a new e-mail confirmation request</a>.{% endblocktrans %}</p>

{% endif %}

{% endblock %}

위에서 봤던 긴급하게 고친 결과가 리다이렉트 했던 창과 같은 파일이다.

이 문제가 발생하던 이유는 결국 url parameter로 넘어온 key와 일치하는 ConfirmEmail DB의 data가 없기 때문에 DoesNotExist exception이 raise가 됬으며, 이 exception을 해결하기 위해 default template인 allauth/template/account/email_confirm.html 가 호출된 것 뿐이였다.

def post(self, *args, **kwargs):
self.object = confirmation = self.get_object()
confirmation.confirm(self.request)

# In the event someone clicks on an email confirmation link
# for one account while logged into another account,
# logout of the currently logged in account.
if (
self.request.user.is_authenticated
and self.request.user.pk != confirmation.email_address.user_id
):
self.logout()

get_adapter(self.request).add_message(
self.request,
messages.SUCCESS,
"account/messages/email_confirmed.txt",
{"email": confirmation.email_address.email},
)
if app_settings.LOGIN_ON_EMAIL_CONFIRMATION:
resp = self.login_on_confirm(confirmation)
if resp is not None:
return resp
# Don't -- allauth doesn't touch is_active so that sys admin can
# use it to block users et al
#
# user = confirmation.email_address.user
# user.is_active = True
# user.save()
redirect_url = self.get_redirect_url()
if not redirect_url:
ctx = self.get_context_data()
return self.render_to_response(ctx)
return redirect(redirect_url)

post 함수에서는 작업이 길어 보이지만 결국 필요한 것은 맨위의 두 줄이다. 나머지는 Django가 풀스택일 경우에서 처리해야 되는 부분이므로 신경을 끈다.

self.get_object() 를 통해 EmailConfirmationHMAC 를 가져오고, confirm 함수를 통해 인증한다.

해결법 — ConfirmEmailView를 상속하기

이를 해결하기 위해서는 ConfirmEmailView 를 그대로 사용하지 말고, 이를 상속해서 바꿔야 할 함수들만 override 해야 한다.

또한 상속해 새로 만들 겸, 일어나는 exception 별로 내가 원하는 메세지를 담아 새로운 template에 보내 경우를 처리하기로 했다.

코드를 각각 함수별 나눠서 설명한다. 모든 함수는 MyConfirmEmailView 의 함수이다.

from allauth.account.views import ConfirmEmailView
from allauth.account import app_settings as allauth_settings

class MyConfirmEmailView(ConfirmEmailView):
allowed_methods = ('GET', 'POST', 'OPTIONS', 'HEAD')

def get(self, *args, **kwargs):
try:
self.object = self.get_object()
if allauth_settings.CONFIRM_EMAIL_ON_GET:
return self.post(*args, **kwargs)
except Http404:
self.object = None

return self.already_confirmed()

get에서는 하는 동작은 post로 작업을 넘기는 것 뿐이다.

메소드 GET 에서는 db의 data를 바꾸는 작업이 일어나면 안된다. 물론 get에서 post 함수를 호출하는 것은 거의 눈가리고 아웅 하는 수준이지만 일단 이렇게 하자.

만약 이미 인증된 키일 경우 self.get_object() 에서 오류가 일어난다(Http404). 그러므로 이 때는 already_confirmed() 를 호출해 사용자로부터 이미 인증된 인증 메일임을 알려주는 template를 렌더링하자.

def post(self, request, *args, **kwargs):
try:
self.object = confirmation = self.get_object()
confirmation.confirm(self.request)

return self.success_render()
except Http404:
return self.fail_render()

post 함수는 받은 key를 confirmation을 처리하는 작업을 한다. 만약 여기서 에러가 일어날 경우 key나 다른 기타 등등의 이유에 의해 인증이 실패 한 것이므로 인증이 실패했다는 template를 렌더링한다. 만약 성공할 경우 success_render() 을 호출한다.

def success_render(self):
return render(self.request, 'user/info.html', {
'head_message': "오늘, 여의도 가입을 축하드립니다.",
'message':
"""
안녕하세요 회원님! 이메일 인증이 정상적으로 완료되었습니다.<br>
<br>
어플리케이션으로 돌아가서 로그인 후 모든 기능을 사용할 수 있습니다.<br>
<br>
오늘도, 오늘, 여의도와 함께 행복한 하루 되시길 바랍니다.<br>
"""
})

렌더링 하는 함수는 위와 같다. rest framework가 아닌 메소드 함수일 경우, request가 self에서 가져와야 된다는 사실을 잊지 말자.

def get(self, *args, **kwargs):
# django의 request는 self.request로 가져온다.
def get(self, request, *args, **kwargs):
# drf의 request는 함수의 인자에서 가져온다.

위와 같이 하면 예쁜 template를 출력하는 것을 볼 수 있다.

오해하기 쉬운 Configuration

앞에서 말했다 싶이 allauth는 django를 풀스택으로 사용한다는 가정 하에 만들어진 라이브러리이기 때문에 rest api와 관계 없는 configuration이 있다.

일단 email confirmation과 관련된 값은 아래와 같다.

ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL(=settings.LOGIN_URL)
ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL (=None)

둘은 이메일 가입 후 로그인 여부에 따라 리다이렉트할 경로를 기입하는 칸이다.

ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL 은 ConfirmEmailView를 그대로 사용할 경우 여기다 적은 url로 인증 후 리다이렉트 한다.

그러나 ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL 는 이미 인증된 링크를 클릭할 경우 리다이렉트 하는 url가 아니다.

여기서 말하는 authenticated는 인증 여부가 아니라 로그인 여부이며 django 창에서 직접 로그인 하는 경우가 없으므로 django를 백으로만 사용할 경우 이 변수는 평생 사용할 일이 없다.

ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION (=False)
ACCOUNT_SIGNUP_REDIRECT_URL (=``settings.LOGIN_REDIRECT_URL``)

ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION는 email confirmation 하자 마자 로그인 처리를 할 것인지 묻는 변수이다.

ACCOUNT_SIGNUP_REDIRECT_URL 는 회원가입 직후 어떤 창으로 리다이렉트 할 것인지 정하는 변수이다. 단 이메일 인증 단계와 같이 중간에 끼어드는 단계가 없는 회원 가입, 즉 uninterruptedly 한 회원 가입일 경우에만 사용하는 변수이다.

로그인이고 회원가입이고 결국 프론트의 각 앱에서 하지 django에서 직접하지 않으므로 상관 없는 변수들이다.

--

--

전승윤/부야

Django가 좋아요, 기존 블로그에서 글을 옮기고 있습니다.