설표의 장고




장고) 회원가입과 로그인, 로그아웃 구현하기(Customizing authentication)





( 수정됨)


환경

OS : Windows 10
python : 3.10.11
Django : 5.0.7

주요 파일 경로

mysite/
|- manage.py
|- config/
|   |- urls.py
|   |- views.py
|   |- settings/
|      |- settings.py
|- member/
|   |- models.py
|   |- urls.py
|   |- views.py
|- templates/
    |- base.html

읽기 전에

이 글은 이전에 작성한 내용을 토대로 합니다.
본문 내용을 따라할 예정이라면 이전 글을 먼저 읽고 동일한 환경에서 진행해주세요.

이 글에서는 장고로 이메일 보내기 설정을 사용합니다.
장고로 이메일 보내는 방법을 먼저 적용하지 않았다면 이 글을 확인하세요.

member app생성하기

사이트를 내용을 구성하기에 앞서, 사이트를 이용하는 사람들의 데이터를 저장할 수 있어야 합니다.
사이트 이용자 정보를 관리할 "member" app을 생성합니다.

명령어는 "django-admin startapp member"입니다.

(django) C:\django\mysite>django-admin startapp member

(django) C:\django\mysite>

models.py 작성하기

member 폴더 안의 models.py를 찾아 다음과 같이 작성해줍니다.

# member/models.py
# code by 설표의장고(django.seolpyo.com)

from django.contrib.auth.models import AbstractUser
from django.contrib.auth.validators import UnicodeUsernameValidator
from django.db import models

class Member(AbstractUser):
    first_name = None # AbstractUser에서 내장하고 있는 Field, None으로 override하여 비활성화
    last_name = None # AbstractUser에서 내장하고 있는 Field, None으로 override하여 비활성화
    email = models.EmailField( # 로그인에 사용할 이메일
        verbose_name='로그인 이메일', # 회원가입 페이지에 노출되는 명칭
        unique=True, # 중복값 비허용
        blank=False, # 이 값은 반드시 입력되어야함
        null=False, # data가 반드시 유효해야함
    )
    username = models.CharField( # 로그인시 표시할 닉네임
        verbose_name='닉네임', # 회원가입 페이지에 노출되는 명칭
        max_length=9, # 최대 9글자
        unique=True, # 중복값 비허용
        blank=False, # 이 값은 반드시 입력되어야함
        null=False, # data가 반드시 유효해야함
        validators=[UnicodeUsernameValidator],
    )
    token_email = models.CharField( # 이메일 인증토큰
        max_length=99,
        null=True, # null 허용
    )

    USERNAME_FIELD = 'email' # email field를 로그인에 사용
    REQUIRED_FIELDS = ['username',] # createsuperuser 명령시 요구하는 field 목록

    # object의 기본 표시명을 username으로 설정
    def __str__(self): return self.username


app 추가하기

settings.py의 "INSTALLED_APPS"에 "member"를 추가해 member app을 등록하고, INSTALLED_APPS 하단에 다음 4개 변수를 추가해줍니다.

# config/settings/settings.py

...
INSTALLED_APPS = [
    'member',
    ...
]

AUTH_USER_MODEL = 'member.Member'
LOGIN_URL = '/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
...

마이그레이션

장고는 모델이 추가되거나 변경된 내용이 있을 때마다 마이그레이션을 진행해주어야 합니다.
"python manage.py makemigrations" 명령으로 마이그레이션을 생성하고,
"python manage.py migrate" 명령으로 마이그레이션을 database에 적용합니다.

(django) C:\django\mysite>python manage.py makemigrations
Migrations for 'member':
  member\migrations\0001_initial.py
    - Create model Member

(django) C:\django\mysite>python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, member, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0001_initial... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying member.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying sessions.0001_initial... OK

(django) C:\django\mysite>

veiws.py 작성하기

회원가입과 이메일 인증을 위한 views.py를 작성합니다.

# member/veiws.py
# code by 설표의장고(django.seolpyo.com)

from urllib.parse import unquote

from django.core.mail import send_mail
from django.contrib import messages
from django.contrib.auth.hashers import make_password
from django.contrib.auth import forms as auth_forms
from django.forms.models import modelform_factory
from django.core.exceptions import BadRequest
from django.shortcuts import redirect, resolve_url, get_object_or_404
from django.utils import timezone
from django.views.generic import CreateView, UpdateView

from .models import Member


class Signup(CreateView):
    "회원가입에 사용하는 class"
    model = Member
    fields = ['email']
    template_name = 'post.html'
    def send_email(self, email, token):
        url = f'{self.request.scheme}://{self.request.get_host()}' + resolve_url('member:verify') # 이메일 인증에 사용하는 url
        send_mail(
            '회원가입 정보를 입력해주세요.',
            message='',
            from_email=None, # 이메일 발송인, None인 경우 setting에 설정된 값을 사용
            recipient_list=[email], # 이메일 수신인 list
            auth_user=None, # SMTP 유저, None인 경우 setting에 설정된 값을 사용
            auth_password=None, # SMTP 비밀번호, None인 경우 setting에 설정된 값을 사용
            html_message=f'<p>다음 버튼을 눌러 회원가입을 완료해주세요.</p><br><form target="_blank" action="{url}"><input type="hidden" name="email" value="{email}"><input type="hidden" name="token_email" value="{token}"><input type="submit" value="회원가입"></form>',
        )
        messages.info(self.request, f'"{email}"으로 회원가입 이메일이 발송되었습니다.<br>만약 5분이 지나도 이메일이 도착하지 않는다면 다시 회원가입을 신청해주세요.')
        return redirect('member:login')
    def form_valid(self, form):
        token = timezone.now() + timezone.timedelta(minutes=10)
        email = form.cleaned_data['email']
        obj = self.model( # object 임시 생성
            email=email,
            username=email,
            password='1', # 비밀번호 임의생성
            token_email=token # 토큰 정보 저장
        )
        obj.save() # pk를 생성하기 위한 저장
        obj.username = f'USER-{obj.pk:0{self.model.username.field.max_length}}' # pk를 이용해 username 임의생성
        obj.save() # username 갱신을 위해 다시 저장
        return self.send_email(email, token) # 회원가입 이메일 보내기
    def form_invalid(self, form):
        errors = form.errors
        if errors.get('email') and '이미 존재' in errors['email'][0]: # 이미 가입한 이메일로 회원가입을 신청한 경우
            email = form.data['email']
            token = timezone.now() + timezone.timedelta(minutes=10)
            obj = self.model.objects.get(email=email) # object 가져오기
            if not obj.token_email: messages.info(self.request, '이미 가입한 이메일입니다.') # 회원가입을 완료한 이메일인 경우
            else: # 이메일 인증이 아직 되지 않은 이메일인 경우
                self.model.objects.filter(email=email).update(token_email=token) # 토큰 변경
                return self.send_email(email, token)
        return super().form_invalid(form)


class Verify(UpdateView):
    "이메일 인증에 사용하는 class"
    model = Member
    form_class = modelform_factory(Member, auth_forms.UserCreationForm, fields=['username'],)
    template_name = 'post.html'
    def get_object(self, queryset=None):
        email = unquote(self.request.GET.get('email', ''))
        token = unquote(self.request.GET.get('token_email', ''))
        obj = get_object_or_404(self.model, email=email,) # object 가져오기
        if not obj.token_email: raise BadRequest('이미 회원가입이 완료된 이메일입니다.') # 이메일 인증은 마친 계정인 경우
        elif obj.token_email != token: raise BadRequest('잘못된 요청입니다.') # 잘못된 토큰을 전달받은 경우
        return obj
    def form_valid(self, form):
        self.object.username = form.cleaned_data['username'] # 닉네임 변경
        self.object.password = make_password(form.cleaned_data['password1']) # 비밀번호 갱신
        self.object.token_email = None # 이메일 토큰 제거
        self.object.save() # 저장
        return redirect(resolve_url('member:login')) # 로그인 페이지로 redirect


class RequestPasswordChange(CreateView):
    "비밀번호 변경 요청에 사용하는 class"
    model = Member
    fields = ['email']
    template_name = 'post.html'
    def send_email(self, email, token):
        url = f'{self.request.scheme}://{self.request.get_host()}' + resolve_url('member:passwordchange') # 비밀번호 변경에 사용하는 url
        send_mail(
            '비밀번호를 변경해주세요.',
            message='',
            from_email=None, # 이메일 발송인, None인 경우 setting에 설정된 값을 사용
            recipient_list=[email], # 이메일 수신인 list
            auth_user=None, # SMTP 유저, None인 경우 setting에 설정된 값을 사용
            auth_password=None, # SMTP 비밀번호, None인 경우 setting에 설정된 값을 사용
            html_message=f'<p>다음 버튼을 눌러 비밀번호를 변경해주세요.</p><br><form target="_blank" action="{url}"><input type="hidden" name="email" value="{email}"><input type="hidden" name="token_email" value="{token}"><input type="submit" value="비밀번호 변경"></form>',
        )
        messages.info(self.request, f'"{email}"으로 비밀번호 변경 이메일이 발송되었습니다.<br>만약 5분이 지나도 이메일이 도착하지 않는다면 다시 요청해주세요.')
        return redirect('member:login')
    def form_valid(self, form):
        messages.info(self.request, f'미가입 이메일입니다.<br>회원가입을 진행해주세요.')
        return redirect('member:login')
        
    def form_invalid(self, form):
        email = form.data['email']
        try: obj = self.model.objects.get(email=email)
        except self.model.DoesNotExist:
            messages.info(self.request, '가입되지 않은 이메일입니다.')
            return redirect(resolve_url('member:login'))
        else:
            if obj.password == '1': # 회원가입시 설정되는 초기 비밀번호가 적용 중인지 확인
                messages.info(self.request, '회원가입이 진행 중이 이메일입니다.<br>이메일이 오지 않았다면 "회원가입"을 다시 신청해주세요.')
                return redirect('member:login')
            token = timezone.now()
            obj.token_email = token # 토큰 생성
            obj.save() # 변경내용 저장
            return self.send_email(email, token,)


class PasswordChange(UpdateView):
    "비밀번호 변경에 사용하는 class"
    model = Member
    form_class = modelform_factory(Member, auth_forms.UserCreationForm, fields=[],)
    template_name = 'post.html'
    def get_object(self, queryset=None):
        email = unquote(self.request.GET.get('email', ''))
        token = unquote(self.request.GET.get('token_email', ''))
        obj = get_object_or_404(self.model, email=email,)
        if not obj.token_email or obj.token_email != token: raise BadRequest('잘못된 요청입니다.')
        return obj
    def form_valid(self, form):
        self.object.password = make_password(form.cleaned_data['password1'])
        self.object.token_email = None
        self.object.save()
        return redirect(resolve_url('member:login'))

urls.py 작성하기

urls.py를 다음과 같이 작성합니다.

# member/urls.py
# code by 설표의장고(django.seolpyo.com)

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

from . import views


app_name = 'member' # url 탐색을 위한 app 명칭, 여기에 속한 url은 "member:{name}" 형식의 이름을 갖게 된다.

urlpatterns = [
    path(
        'login/', # 로그인에 사용할 url
        auth_views.LoginView.as_view( # 수행할 작업
            template_name='login.html', # 로그인 페이지에서 사용하는 template
            redirect_authenticated_user=True # 로그인한 이용자가 접근한 경우 redirect
        ),
        name='login', # url name, app_name과 조합되므로 호출하는 이름은 "memeber:login"이 된다.
    ),
    path(
        'logout/', # 로그아웃에 사용할 url
        auth_views.LogoutView.as_view(), # 수행할 작업
        name='logout', # url name, app_name과 조합되므로 호출하는 이름은 "memeber:logout"이 된다.
    ),
    path('signup/', views.Signup.as_view(), name='signup',), # 회원가입
    path('verify/', views.Verify.as_view(), name='verify',), # 이메일 인증
    path('resetpassword/', views.RequestPasswordChange.as_view(), name='passwordreset',), # 비밀번호 초기화
    path('changepassword/', views.PasswordChange.as_view(), name='passwordchange',), # 비밀번호 변경
]

url 할당하기

member app의 url을 작성했으니, 이번에는 config 폴더에 있는 urls.py에서 member app의 url을 사용할 수 있도록 만들어야 합니다.
다음과 같이 "member/" path를 추가합니다.

from django.contrib import admin
from django.urls import path, include

from . import views

urlpatterns = [
    path('admin/', admin.site.urls),
    path('member/', include('member.urls')), # member/ 디렉토리 접근시 member app의 urls.py를 사용
    path('', views.home),
]

home function 수정하기

이전에 작성한 config 폴더의 views.py를 다음과 같이 수정합니다.

# config/views.py

from django.shortcuts import render

def home(request):
    return render(request, 'base.html')

템플릿 작성하기

base.html

templates 폴더에 base.html을 다음과 같이 작성합니다.

# templates/base.html

{% load static %}
<html>
    <head>
        <link rel="stylesheet" href="{% static 'style.css' %}">
    </head>
    <body>
        <header>
            <a href="/">설표의 장고</a>
        </header>
        <div style="padding-top: 40px;">
            <nav>
                {% if request.user.is_anonymous %}
                {# 로그인하지 않은 경우 #}
                <form action="{% url 'member:login' %}?next={{ request.path_info }}">
                    <input type="submit" value="로그인">
                </form>
                {% else %}
                {# 로그인한 경우 #}
                <div class="member" style="display: flex;">
                    {% if request.user.is_staff %}[관리자]{% endif %} "{{ request.user }}"님
                    <form method="post" action="{% url 'member:logout' %}" style="display: inline-block; margin-left: auto;">
                        {% csrf_token %}
                        <input type="submit" value="로그아웃">
                    </form>
                </div>
                {% endif %}
            </nav>
            <hr>
            <br>
            <div class="message">
                {# 알림 메세지가 있는 경우 메세지 표시 #}
                {% if messages %}
                    {% for msg in messages %}
                        {{ msg|safe }}
                    {% endfor %}
                {% endif %}
            </div>
            <main>
                {# base.html을 상속받는 템플릿에서 사용 가능한 block #}
                {% block main %}{% endblock %}
            </main>
            </div>
    </body>
    <footer style="border-top: #000 1px solid; text-align: center; padding-top: 20px;">
        <div>
            <a target="_blank" href="https://www.djangoproject.com/">Powered by Django</a>
        </div>
        <div>
            <a target="_blank" href="https://django.seolpyo.com/">Designed by 하얀설표</a>
        </div>
    </footer>
</html>

login.html

로그인 페이지에 사용되는 login.html을 다음과 같이 작성합니다.

# templates/login.html

{# base.html 을 상속받아 사용하는 명령어 #}
{% extends "base.html" %}

{# base.html에 선언된 {% block main %}에 삽입되는 내용 #}
{% block main %}
// code by 설표의장고(django.seolpyo.com)
<form method="post">
    {% csrf_token %}
    {{ form }}
    <input type="submit">
</form>
<a href="{% url 'member:signup' %}">회원가입</a>
<a href="{% url 'member:passwordreset' %}">비밀번호 변경</a>
{% endblock %}

post.html

회원가입에 사용할 post.html을 다음과 같이 작성합니다.

# templates/post.html

{# base.html 을 상속받아 사용하는 명령어 #}
{% extends "base.html" %}

{# base.html에 선언된 {% block main %}에 삽입되는 내용 #}

{% block main %}
// code by 설표의장고(django.seolpyo.com)
{{ form.media }}
<form method="post">
    {% csrf_token %}
    {{ form }}
    <input type="submit">
</form>
{% endblock %}

장고 프로젝트 실행하기

이제 장고 프로젝트로 운영되는 사이트에서 회원가입과 로그인, 로그아웃을 수행할 수 있습니다.
모든 템플릿이 base.html을 상속받고 있으므로 모든 페이지에서 로그인과 로그아웃 작업을 수행할 수 있습니다.

이메일 주소로 이메일을 발송하고, 거기서 다시 회원가입 페이지를 구현하는 과정이 정말 번거롭습니다.
그러나 이렇게 하지 않으면 "aaa@aaa.com"과 같이 이메일 형식만 맞춰주면 회원가입이 이루어져 최소한의 검증 절차를 진행하는 것이 좋습니다.
장고 로그인 구현 확인



이 글의 댓글 기능은 일부러 막아놓았습니다. 궁금한 내용이 있다면 게시판을 이용해주세요!


공감 : 0