Create User Login 구현(django.contrib.auth)

김의석 ·2024년 5월 27일

Hello! Poko Ver.2

목록 보기
3/28

1. 배경

현재 사용자 코드를 이용한 로그인 체계에 대한 아래 피드백을 참고하여 회원가입을 통한 로그인 체계를 구현한다.

1) 사용자 코드를 이용한 로그인 체계는 추후 서비스 확장성이 좋지않다.
2) 사용자를 식별하는 데이터가 불충분하여 사용자 인증절차 및 보안이 취약하다.
3) 사용자 코드 분실 시 프로세스가 자동화 되지 않음.

2. 요구사항과 핵심기능

  • 회원가입 및 비밀번호 분실/찾기 프로세스 자동화(개발단에서의 관리를 최소화)
  • 사용자 식별을 위한 데이터 추가로 사용자 인증절차를 강화

3. 설계

1) Model

(1) 사용자 식별을 위한 데이터 불충분을 해결하기 위해 User model을 확장한다.

django.contrib.auth의 기본 User 모델 확장 방법

  • 상속
  • one to one 필드를 이용한 프로필 모델생성

GPT : 두 가지 방법 중 첫 번째 방법(상속)은 더 많은 유연성을 제공하며, 사용자 정의 User 모델이 필요한 경우에 유용합니다. 두 번째 방법은 기본 User 모델을 그대로 사용하면서 추가 필드를 별도로 관리할 수 있어, 기존 프로젝트에 더 쉽게 통합할 수 있습니다.

: 첫번째 방법이 더 많은 유연성을 제공하는 이유는 무엇인가요?
제가 생각하기에 유연성에 대해서는 두가지 방법이 특별한 차이가 없는 것 같은데
유연성 관점에서 두 방법이 어떤 차이가 있는지 알려주세요.

상속을 이용한 확장

장점

  • 중앙 집중화된 관리:
    모든 사용자 관련 필드가 하나의 모델에 포함되어 있어, 코드의 일관성과 관리가 용이합니다.

  • Django 기본 인증 시스템과의 완전한 통합:
    확장된 User 모델이 Django의 기본 인증 시스템과 완벽하게 통합됩니다. 즉, Django의 기본 폼, 어드민, 인증 백엔드 등 모든 기본 기능이 바로 작동합니다.

  • 추가 기능 구현의 용이성:
    사용자 정의 User 모델에 메서드나 추가 필드를 쉽게 추가할 수 있습니다. 예를 들어, 사용자에 관한 추가적인 속성이나 동작을 구현할 때 유용합니다.

  • 다형성 지원:
    필요에 따라 다른 앱이나 서비스에서 상속받은 User 모델을 확장하거나 다형성을 활용하여 여러 종류의 사용자 모델을 만들 수 있습니다.

단점

  • 초기 설정의 복잡성:
    초기 설정이 다소 복잡할 수 있으며, 특히 기존 프로젝트에 적용할 때는 더 많은 마이그레이션 작업이 필요합니다.

  • 기존 데이터 마이그레이션:
    기존 User 데이터를 새 모델로 마이그레이션해야 할 경우 복잡할 수 있습니다.

one to one 필드를 가진 프로필 모델 생성을 이용한 확장

장점

  • 간단한 설정:
    기존 User 모델을 그대로 사용하면서 추가 필드를 별도의 프로필 모델로 관리할 수 있어 설정이 간단합니다.

  • 기존 프로젝트에 쉽게 통합:
    기존 프로젝트에서 기본 User 모델을 변경하지 않고도 쉽게 추가 기능을 구현할 수 있습니다.

단점

  • 두 개의 모델 관리 필요:
    사용자 정보가 두 개의 모델(User와 UserProfile)에 나누어져 있어, 관리가 다소 번거로울 수 있습니다. 예를 들어, 사용자 정보를 조회할 때마다 두 개의 모델을 조인해야 하는 등의 작업이 필요합니다.

  • Django 기본 인증 시스템과의 통합 제한:
    기본 User 모델과 프로필 모델이 분리되어 있기 때문에, 기본 User 모델에만 관련된 일부 Django 기능에서 프로필 정보를 직접 사용할 수 없습니다. 이를 해결하려면 추가적인 코드 작성이 필요합니다.

  • 추가적인 코드 작성:
    프로필 정보를 포함한 사용자 데이터를 처리할 때, 항상 User와 UserProfile 모델을 함께 사용해야 하므로 코드가 복잡해질 수 있습니다.

결론

상속의 장점인 중앙 집중화된 관리확장성이 요구사항을 만족하고 특히 Django 기본 인증 시스템과의 완전한 통합은 기존의 인증 시스템을 특별히 손보지 않아도 되기 때문에 주저할 것 없이 상속을 이용해 User 모델을 확장하기로 한다!

AbstractUser 파악해보자!

django.contrib.auth에서 제공하는 User model을 확장하기위해 django.contrib.auth.modelsAbstractUser을 상속한다!

class AbstractUser(AbstractBaseUser, PermissionsMixin):
    """
    An abstract base class implementing a fully featured User model with
    admin-compliant permissions.

    Username and password are required. Other fields are optional.
    """

AbstractUser는 아래 두가지 class를 상속받는다.

  • AbstractBaseUser : 기본 user모델을 구현하는 데 필요한 최소한의 기능을 제공하는 추상 기본 클래스.
  • PermissionsMixin : user에게 권한 시스템을 추가하는 데 사용

주석
이 클래스는 관리자 권한을 준수하면서 모든 기능이 구현된 사용자 모델을 구현하는 추상 기본 클래스입니다. 사용자 이름과 비밀번호는 필수이며, 다른 필드는 선택적입니다. 이 클래스를 상속하여 커스텀 사용자 모델을 정의할 수 있습니다.

AbstractUser에 포함된 메서드는 다음과 같다.

username_validator = UnicodeUsernameValidator()

    username = models.CharField(
        _("username"),
        max_length=150,
        unique=True,
        help_text=_(
            "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
        ),
        validators=[username_validator],
        error_messages={
            "unique": _("A user with that username already exists."),
        },
    )
    first_name = models.CharField(_("first name"), max_length=150, blank=True)
    last_name = models.CharField(_("last name"), max_length=150, blank=True)
    email = models.EmailField(_("email address"), 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=True,
        help_text=_(
            "Designates whether this user should be treated as active. "
            "Unselect this instead of deleting accounts."
        ),
    )
    date_joined = models.DateTimeField(_("date joined"), default=timezone.now)

    objects = UserManager()

    EMAIL_FIELD = "email"
    USERNAME_FIELD = "username"
    REQUIRED_FIELDS = ["email"]

    class Meta:
        verbose_name = _("user")
        verbose_name_plural = _("users")
        abstract = True

    def clean(self):
        super().clean()
        self.email = self.__class__.objects.normalize_email(self.email)

    def get_full_name(self):
        """
        Return the first_name plus the last_name, with a space in between.
        """
        full_name = "%s %s" % (self.first_name, self.last_name)
        return full_name.strip()

    def get_short_name(self):
        """Return the short name for the user."""
        return self.first_name

    def email_user(self, subject, message, from_email=None, **kwargs):
        """Send an email to this user."""
        send_mail(subject, message, from_email, [self.email], **kwargs)
  • username_validator :
    username의 유효성을 검사하기 위한 UnicodeUsernameValidator의 인스턴스이다! username이 조건을 충족하는지 검사에 사용

  • objects = UserManager() :
    user 객체를 생성하고 쿼리하는 기능을 정의할 수 있다. user를 생성하고, username으로 user의 field를 가져오는 등의 기능을 수행한다!
    AbstractUser에서는 clean()에서 user의 e-mail을 표준화화하여 객체로 가져오는 것을 기능을 했다.

  • email_user() :
    사용자에게 이메일을 보낼수 있다. 내부에서는 send_mail()를 사용하여 이메일을 보내므로, 이메일은 실제로 사용자가 등록한 이메일 주소로 전송된다고 한다!

요구사항에 맞게 CustomUser에 field 추가하기!

class CustomUser(AbstractUser):
    full_name = models.CharField(max_length=4)
    birth_date = models.DateField(null=True, blank=True)

    def __str__(self):
        return self.username

full_name : 기존에는 first name + last name을 연산하는 코드를 추가하여 사용하였으나 이번 확장을 통해 full name을 한번에 입력 받기로 했다.

birth_date : 현재 보유 데이터 중에서 가장 사용자 식별에 적합한 생년월일 필드를 추가하였다.

User Model 확장 후 설정

AUTH_USER_MODEL = "auth.User"

AUTH_USER_MODEL = "account.CustomUser"
  • settings.py : AUTH_USER_MODEL를 "account.CustomUser"로 설정!
 class Meta:
        model = CustomUser
        fields = ("username", "full_name", "password1", "password2")
  • 사용중인 forms.py에서의 설정 : 현재 회원가입 forms.py이다. 기본 지정 User model을 model = CustomUser로 설정!

모든 마이그레이션 초기화 및 재적용

User Model 확장 후 기존의 데이터가 반영되지 않는다는 것을 구현하면서 알게 되었다,, 계속해서 makemigrations-migrate를 수행해도 CustomUser 모델 테이블이 반영되지 않았던,, 알고보니 미리 DB 백업을 해놔야한다고 한다.
상속을 통한 확장이라 미처 생각하지 못한 부분,,뭐든 한번 설계할 때 정확히 해야겠다는 깨달음을 얻었지만 이런 경우에는 sql을 비롯해 모든 migarations 파일을 초기화해야한다고,, 이때 사용한 코드를 기록한다!

migrations 파일 삭제
find . -path "*/migrations/*.py" -not -name "__init__.py" -delete
find . -path "*/migrations/*.pyc" -delete
sqlite3 데이터베이스 삭제
rm db.sqlite3
python3 manage.py makemigrations
python3 manage.py migrate
sqlite3 db.sqlite3
.tables

2) FE

(1) React

3) BE

(1) 회원가입

(2) LoginView 확장 로그인

django 기본 로그인 로직은 다음과 같다!

핵심 로직(django.contrib.auth.backends.ModelBackend)

첫 번째, User 모델에서 활성화 user 중 입력 user와 PWD가 일치하는 user를 찾아 인증한다.

# 예시 : 입력 user와 pwd
username = request.POST.get("username")
password = request.POST.get("password")

try:
    user = User.objects.get(username=username, is_active=True)
except User.DoesNotExist:
return HttpResponse("인증 실패", status=400)


if user.check_password(password) is False:
    return HttpResponse("인증 실패", status=400)
  • 암호 필드는 해싱되어 저장되어있기에 단순 str 비교(user.password == password)로 일치 여부를 검사할 수 없고 .check_password 메서드를 사용해서 일치 여부를 검사한다!
from django.contrib.auth import authenticate

user = authenticate(request, username=username, password=password)
  • auth app의 authenticate를 통해 사용자를 인증!
  • 인증 성공 시 User 모델 인스턴스 반환, 실패 시 None을 반환
두 번째, user와 PWD 인증 후에는 세션을 통해 조회할 수 있도록 아래의 정보를 세션에 담는다.
request.session["_auth_user_backend"] = "django.contrib.auth.backends.ModelBackend"
request.session["_auth_user_id"] = user.pk
request.session["_auth_user_hash"] = user.get_session_auth_hash()
  • user를 조회한 방법
  • user pk
  • 인증 해시 : 세션의 유효성 검증을 목적(str, sha256 해싱 알고리즘)
from django.contrib.auth import login as auth_login

auth_login(request, user)
  • login 메서서드는 한번에 세션 키를 생성하고, 세션에 위의 3개 정보를 남기고, CSRF 토큰도 재생성한다!
  • User 모델의 .last_login 필드를 현재 시각으로 업데이트도 한다.
세 번째, Login Redirect URL로 이동
return redirect(settings.LOGIN_REDIRECT_URL)

next_url = request.POST.get("next") or request.GET.get("next") or settings.LOGIN_REDIRECT_URL
        return redirect(next_url)
  • settings의 LOGIN_REDIRECT_URL로 로그인 후 이동!
  • 우선순위!
    1등 request.POST.get("next")
    2등 request.GET.get("next")
    3등 settins.LOGIN_REDIRECT_URL

poko에 사용된 LoginView 확장 로그인

LoginView는 RedirectURLMixin와 FormView를 상속 받는다.
RedirectURLMixin은 인증된 사용자를 리디렉션하며 FormView는 사용자 로그인 양식을 표시하고 로그인 동작을 한다.

다음은 주요 메서드와 옵션이다!


class LoginView(RedirectURLMixin, FormView):
    """
    Display the login form and handle the login action.
    """
    
    form_class = AuthenticationForm
    authentication_form = None
    template_name = "registration/login.html"
    redirect_authenticated_user = False
    extra_context = None
    
    
     def form_valid(self, form):
        """Security check complete. Log the user in."""
        auth_login(self.request, form.get_user())
        return HttpResponseRedirect(self.get_success_url())
        
      def get_default_redirect_url(self):
        """Return the default redirect URL."""
        if self.next_page:
            return resolve_url(self.next_page)
        else:
            return resolve_url(settings.LOGIN_REDIRECT_URL)

LoginView 클래스에서 사용자 인증을 직접 처리하는 authenticate에 해당하는 메서드는 존재하지 않는다. 대신, 사용자 인증은 AuthenticationForm 내에서 처리된다.(clean(), confirm_login_allowed())

get_form_kwargs 메서드는 request 객체를 폼 인스턴스에 전달하고
AuthenticationForm은 authenticate를 호출하여 이 request 객체에 대한 사용자를 인증한다!

  • form_valid은 유효한 form을 인자로 받아 auth_login을 수행한다.
  • get_default_redirect_url은 next_page 설정 시에는 해당 URL로 리디렉션하고, 아니면 LOGIN_REDIRECT_URL로 리디렉션한다!

세션데이터로 User를 조회하는 시점!

MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
# 첫 번째 request.Session 객체를 할당, 이후 user_id, use_backend, user_ hash를 저장하는데 활용된다.
"django.contrib.auth.middleware.AuthenticationMiddleware" , 
# 두 번째 request.user를 할당!
]
  • 인증에는 세션 지원이 필수! 반드시 SessionMiddleware -> AuthenticationMiddleware로 선언되어야한다.
class AuthenticationMiddleware(MiddlewareMixin):
    def process_request(self, request):
        request.user = SimpleLazyObject(lambda: get_user(request))
        
        
def get_user(request):
    if not hasattr(request, "_cached_user"):
        request._cached_user = auth.get_user(request)
    return request._cached_user        
  • 세션으로 부터 user_backend, user_id, user_hash 값으로 user 조회

  • request.user
    process_request 메서드는 request에 user 속성을 추가! SimpleLazyObject로 매핑되어 실제로 user 속성이 필요할 때까지 사용자 객체를 실행하지 않음

  • SimpleLazyObject
    request.user에 접근할 때까지 lambda: get_user(request)를 실행하지 않는다.
    ++ 추가 : 프로그래밍에서 특정 값을 필요로 할 때까지 해당 값을 계산하지 않고, 필요한 시점에 계산하는 방식을 의미. 이는 성능 최적화 및 자원 효율성을 향상시킬 수 있는 패턴 중 하나!

세션데이터로 User를 조회 전체 과정

요청 처리 시작, 클라이언트로부터 HTTP 요청

미들웨어 처리, AuthenticationMiddleware의 process_request 메서드가 호출 request.user 속성에 SimpleLazyObject(lambda: get_user(request))를 설정

요청 객체에서 사용자 정보 접근, 뷰나 다른 코드에서 request.user에 접근하면, SimpleLazyObject를 통해 get_user(request) 함수를 호출.

사용자 정보 조회, get_user 함수는 _cached_user 속성을 확인하고, 없으면 auth.get_user(request.session)를 호출하여 사용자 정보를 조회하고, 이를 _cached_user에 캐시!.

사용자 정보 반환, get_user 함수는 캐시된 사용자 객체를 반환하고, SimpleLazyObject는 이를 request.user에 할당한다!

(3) 비밀번호 변경

(4) 비밀번호 재설정

핵심 로직(PasswordResetView, PasswordResetForm)

첫 번째, 사용자에게 이메일을 입력 받는다!
email = forms.EmailField(
        label=_("Email"),
        max_length=254,
        widget=forms.EmailInput(attrs={"autocomplete": "email"}),
    )
    
 # EmailField의 유효성검사
class EmailField(CharField):
    widget = EmailInput
    default_validators = [validators.validate_email]

    def __init__(self, **kwargs):
        # The default maximum length of an email is 320 characters per RFC 3696
        # section 3.
        kwargs.setdefault("max_length", 320)
        super().__init__(strip=True, **kwargs)
  • PasswordResetForm의 EmailField를 통해 입력받는다.
  • 이때 EmailField는 포멧에 대한 유효성 검사만 진행, 이메일 존재유무는 확인하지 않는다!
두 번째, 입력받은 이메일로 user를 조회하고 uidb64+token으로 구성된 resetPWD url을 생성 후 user가 입력한 이메일로 발송한다!
def save(self, ++생략++)
 for user in self.get_users(email):
            user_email = getattr(user, email_field_name)
            context = {
                "email": user_email,
                "domain": domain,
                "site_name": site_name,
                "uid": urlsafe_base64_encode(force_bytes(user.pk)),
                "user": user,
                "token": token_generator.make_token(user),
                "protocol": "https" if use_https else "http",
                **(extra_email_context or {}),
            }
  • PasswordResetForm의 save() 메서드 내에 get_users()와 default_token_generator를 사용
  • get_users()
    입력 이메일을 가지고, "is_active": True이며 has_usable_password() 가능한 user를 조회,
  • default_token_generator
    timestamp, user pk, PWD, last login, email필드 값을 기반으로 settings.SCRECTKEY를 활용하여 생성, sha256 해싱 알고리즘으로 문자열을 생성
http://localhost/reset/NA(user pk)/c81m7x(암호 재설정 링크 생성 시간)-c7d90bb2adb62befe61c6f1ce9615bd4(sha256 해싱 문자열)/
  • 생성 된 url 링크
 def send_mail(
        self,
        subject_template_name,
        email_template_name,
        context,
        from_email,
        to_email,
        html_email_template_name=None,
    ):
  • PasswordResetForm의 send_mail()은 비밀번호 재설정을 위한 6개의 인자를 받아 메일을 발송한다!
django에서 email을 발송하기 위한 setting!

SMTP란?
Simple Mail Transfer Protocol의 약자로 이메일을 전송하기 위해 사용하는 프로토콜이다!

# django의 global_settings

# to a module that defines an EmailBackend class.
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"

# Host for sending email.
EMAIL_HOST = "localhost"

# Port for sending email.
EMAIL_PORT = 25

django 프로젝트에서 이메일을 발송하기 위해 프로젝트 settings.py에서 다음과 같이 설정한다. 이때 메일 계정정보를 프로젝트에 코드로 작성하지말고 반드시 환경변수 파일(.env)을 생성할 것!

# settings.py

EMAIL_HOST = env.str(var="EMAIL_HOST", default=None)

# 환경변수로 읽어온 EMAIL_HOST가 None이면 console.EmailBackend를 수행!
if DEBUG and EMAIL_HOST is None:
    EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
else:
    try:
        EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
        EMAIL_PORT = env.int("EMAIL_PORT")
        EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", default=False)
        EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=False)
        EMAIL_HOST_USER = env.str("EMAIL_HOST_USER")
        EMAIL_HOST_PASSWORD = env.str("EMAIL_HOST_PASSWORD")
        DEFAULT_FROM_EMAIL = env.str("DEFAULT_FROM_EMAIL")

환경변수 사용을 위한 세팅을 추가한다!

# settings.py

from environ import Env

BASE_DIR = Path(__file__).resolve().parent.parent

env = Env()
ENV_PATH = BASE_DIR / ".env"
if ENV_PATH.exists():
    print("환경변수 파일 있으니까 읽어올게! .env file")
    with ENV_PATH.open(encoding="utf-8") as f:
        env.read_env(f, overwrite=True)
else:
    print("ENV_PATH에 환경 변수가 없다!", ENV_PATH)

여기까지하면 django에서 이메일 발송을 위한 모든 세팅이 끝난다! 깔깔

세 번째, user는 메일을 통해 확인한 url로 접속하여 비밀번호를 재설정한다!

  • SetPasswordForm에서 입력한 비밀번호의 유효성 검사(포멧)을 시전!
네 번째, user가 보낸 재설정 url에 대한 유효성 검사를한다!
    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)
  • PasswordResetConfirmView의 dispatch()에서 url에 대한 유효성 검사를 한다.
  • 이때 재설정 요청 url에 구성된 uidb64(urlsafe_base64_decode)와 토큰 정보(default_token_generator.check_token(user, toke))를 통해 유효성 검사를 한다.
  • 왜 하는건가?
    user object(토큰 구성 요소가 저장 된)를 요청으로 들어온 재설정 url의 uid와 token을 비교하여 재설정 요청한 사용자가 실제 서버에 등록된 user인지 확인하기 위해서이다!
재설정 확인 url의 uidb64와 token은 어떻게 view에 전달되는거지?
# urls.py

urlpatterns = [
    path('reset/<uidb64>/<token>/', auth_views.PasswordResetConfirmView.as_view(),
]

# dispatch()

    def dispatch(self, *args, **kwargs):
        if "uidb64" not in kwargs or "token" not in kwargs:
  • urls.py에서 설정된 url은 uidb64와 token을 포함한다.
  • django는 해당 url과 일치하는지 확인, 일치하면 해당 뷰를 호출한다.
    이때 url에서 추출된 uidb64와 token이 뷰의 dispatch()로 전달된다고 한다.
  • dispatch는 kwargs를 통해 전달 받는다고! kwargs는 url에서 추출된 모든 매개변수를 포함하는 dictionary이다.
def test(**kwargs):
    for key, value in kwargs.items():
        print(f"{key} = {value}")

my_function(name="김의석", age=30, city="Tokyo")
  • kwargs의 사용의 기본 예제
  • django에서는 url 패턴이 매칭 될 때 django 디스패처(django.urls 패키지에 포함)를 통해 url에서 추출된 변수가 kwargs에 사전형태로 저장된다고 한다!
다섯 번째, 재설정 된 PWD에 대한 유효성 검사를 한다!
 def clean_new_password2(self):
     password1 = self.cleaned_data.get("new_password1")
     password2 = self.cleaned_data.get("new_password2")
     if password1 and password2 and password1 != password2:
        raise ValidationError(
            self.error_messages["password_mismatch"],
            code="password_mismatch",
            )
      password_validation.validate_password(password2, self.user)
      return password2
  • POST로 재설정한 PWD를 전달받은 SetPasswordForm의 clean_new_password2()에서 비밀번호 유효성 검사를 진행!
여섯 번째 재설정 된 PWD을 DB에 저장한다!
def save(self, commit=True):
      password = self.cleaned_data["new_password1"]
      self.user.set_password(password)
      if commit:
          self.user.save()
      return self.user
  • 마지막으로 SetPasswordForm에서 save()에서 재설정 비밀번호를 set_password() 해싱하여 DB에 저장한다!

4) 도식화

profile
널리 이롭게

0개의 댓글