[Django] generic.CreateView를 이용한 회원가입 후 자동 로그인하기 삽질 과정

dj-yang·2021년 7월 17일
2

django

목록 보기
2/7
post-thumbnail

서론

개발자 커뮤니티를 제작 중 이메일 인증 기능을 넣을 필요성이 느껴졌고 개발을 시작했다.
로직은 회원가입 -> 인증 이메일 발송 -> 인증 이메일 발송 안내(이메일 재발송 가능) 만들 생각이다. 이메일 인증을 할 때 어떤 유저의 이메일 필드를 인증한 것인지를 구분할 수 있어야 했기 때문에 해시된 스트링을 인증 값으로 사용했다.

위의 과정을 진행하면서 인증 이메일 발송 안내 페이지에도 유저 객체 정보를 가지고 있어야 한다는 것을 깨달았고, 회원 가입 후 자동 로그인기능이 필요했다.

이 글은 내가 자동 로그인을 구현하면서 생긴 오류와 오류 발생 원인에 대해서 삽질한 내용에 대해서 다루고 있다.


구현 과정

초기 구현

django generic view의 CreateView의 코드를 보면서 어느 부분에 로그인 로직을 넣어야 하는 지에 대해서 고민하던 도중 사용자 정보에 대한 validation 검사를 끝내야 실행되는 로직인form_valid() 메서드에 삽입하기로 했다.

초기 구현 CreateView/form_valid()

class SignUpView(CreateView):
  ...
  def form_valid(self, form):
    user = form.save()
    login(self.request, new_user)
    return super().form_valid(form)

form_valid() 메서드의 전체 동작을 바꾸려는 것이 아니라 기존의 로직 내부에 로그인 과정만 추가하고 싶기 때문에 내가 원하는 로직 후에는 super()를 통해 부모 클래스의 form_vaild() 메서드를 호출했다.

이 과정에서 살짝 걱정했던 부분은 부모 클래스의 메서드에서 self.object = form.save() 코드가 존재하기 때문에 내가 구성한 로직대로라면 form.save()를 두 번 호출하게 된다. 혹시 오류가 있지는 않을까 걱정을 했지만 다행히 문제 없이 회원 가입 과정을 진행할 수 있었다.

문제 발생

다행히 에러가 발생하지 않았기 때문에 간단한 테스트 후 넘어가려고 했지만.. 이럴 수가...! 내가 구성한 코드로는 로그인이 되지 않았다..

차라리 위에서 예상하던 것처럼 form.save()가 두번 발생해서 유니크한 속성 때문에 오류가 발생한 것이라면 이해가 되었지만 User 객체도 제대로 생성이 되었고, 인증 메일도 정상적으로 동작했다. 후..

문제 해결(stackoverflow)

결국 구글링을 통해 해결방법에 대해서는 확인할 수 있었다. 변경된 코드는 아래와 같다.

class SignUpView(CreateView):
 ...
 def form_valid(self, form):
   valid = super().form_valid(form)
   login(self.request, self.object)
   return valid

미리 super()클래스를 실행시켜 그 결과 값을 가지고 있고 그 후 로그인 과정을 진행한 다음 슈퍼 클래스의 리턴 값을 반환하면 로그인도 성공적으로 진행된다.

여기에서 왜 이런 경우가 생길 지에 대해서 궁금증이 생겼다.

추론

1. 다중 상속으로 인한 오류

파이썬 CBV(Class-Based View)는 다중 상속을 통해 개발자에게 빠르게 개발할 수 있게 도와준다. 따라서 내가 미처 발견하지 못 하지만 login에 문제가 생길 수 있는 부분이 있을 것이라고 예상했다.

예를 들어 새로 회원가입을 할 경우 보통 기존 로그인 세션을 해제하고 새로운 세션으로 로그인 하는 서비스들이 존재했다.

하지만 거의 10시간에 걸친 반복된 django 코드를 둘러봤지만 실질적으로 영향을 끼치는 코드를 발견하기 어려웠다. 결국 django로 실무를 진행하고 있는 지인에게 도움을 요청해서 같이 머리를 맞대기 시작했다.

2. form.save()가 2번 진행되며 User 객체가 2개가 생겨서 생기는 오류

정확한 내부 원인은 모르겠지만 머리를 맞대고 처음 나온 의견이었다. 하지만 곧, 이부분은 생각하지 않았는데 그 이유는 두 가지였다.

  1. 회원 가입 과정에서 별도의 오류가 생기지 않았다.
  2. username과 email 필드는 unique 속성을 가지고 있었기 때문에 만약 form.save()가 정상적으로 2번 일어났다면 에러가 발생했었어야 했다.

3. 우리가 확인할 수 없는 위치에서의 로직이 세션을 초기화 시킨다.

django 프레임 워크 같은 경우는 middleware와 WSGI 등을 이용한다. 해당 과정을 진행하는 와중에 세션이 초기화 된다.

이 추론을 증명하기 위해서 IDE에서 제공하는 Debug Tool을 이용해서 초기 구현(실패했던 구현) 로직을 따라 가기 시작했다.

로직을 따라갔을 때 발견한 점은 AuthenticationMiddleWare를 실행하는 도중에 session 정보가 사라진다는 점이다.(장고의 로그인 여부는 sessionid를 통해 결정된다.)

다음 코드에서 세션이 초기화 되었다.

request.user = SimpleLazyObject(lambda: get_user(request))

4. Lazy 방식으로 인해 오류가 발생했다.

해당 코드의 SimpleLazyObject()를 보자마자 지인이 거론한 원인이었다. lazy 방식은 쉽게 말해서 값이 필요로 할 때 해당 값을 계산하는 방식인데 그로 인해 오류가 발생한 것이 아닐까 추측했다.

하지만 내가 구성한 코드의 다른 로직 중 초기 구현과 유사한 부분이 존재했는데 같은 방식임에도 불구하고 정상적으로 동작했다.

다시 3번에서 발견한 부분에 집중했다. 그 중 get_user() 함수의 코드를 살폈다.

# django.contrib.auth.middleware.py
def get_user(request):
    if not hasattr(request, '_cached_user'):
        request._cached_user = auth.get_user(request)
    return request._cached_user

request 속성 중에 _cached_user 속성이 없을 경우 auth.get_user(request)를 호출해 해당 속성을 추가시켜주는 함수였고, 해당 속성이 유저의 로그인 여부를 결정하는데 중요한 속성임을 인지했다.

그 다음에 auth.get_user(request) 부분을 살펴보기로 했다.

# django.contrib.auth.__init__.py
def get_user(request):
    """
    Return the user model instance associated with the given request session.
    If no user is retrieved, return an instance of `AnonymousUser`.
    """
    from .models import AnonymousUser
    user = None
    try:
        user_id = _get_user_session_key(request)
        backend_path = request.session[BACKEND_SESSION_KEY]
    except KeyError:
        pass
    else:
        if backend_path in settings.AUTHENTICATION_BACKENDS:
            backend = load_backend(backend_path)
            user = backend.get_user(user_id)
            # Verify the session
            if hasattr(user, 'get_session_auth_hash'):
                session_hash = request.session.get(HASH_SESSION_KEY)
                session_hash_verified = session_hash and constant_time_compare(
                    session_hash,
                    user.get_session_auth_hash()
                )
                if not session_hash_verified:
                    request.session.flush()
                    user = None

    return user or AnonymousUser()

실패한 로직을 따라가니 session_hash_verified가 False 값을 가지고 있었고 밑의 if 문이 실행되어 session이 초기화 되고 user에 None이 들어가면서 AnonymousUser()가 request._cached_user 속성에 들어가고 있었다.

해당 부분의 get_session_auth_hash() 함수를 살펴보면 아래와 같다

    def get_session_auth_hash(self):
        """
        Return an HMAC of the password field.
        """
        key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash"
        return salted_hmac(
            key_salt,
            self.password,
            algorithm='sha256',
        ).hexdigest()

본인의 비밀번호를 이용해 해시 값을 반환하는 함수인데, 즉 request하고 있는 유저의 세션에 가지고 있는 해시 값과 저장되어 있는 유저 정보의 해시 값이 동일하지 않아서.. 다른 유저가 Anonymous를 반환한다는 소리였다.


결론

정리하자면 내가 초기에 구현한 로직은 form.save()가 2번 실행되서 발생하는 문제였다.

form.save()는 authenticationform의 save() method를 실행하는데 해당 save()메서드의 동작 과정 중 set_password()라는 함수를 통해 유저의 비밀번호를 만들어서 객체에 저장시킨다.

set_password()는 make_password()를 호출하고 make_password()는 sha 해시 알고리즘을 사용해 해시된 비밀번호를 반환하는데 이때 랜덤하게 바뀌는 salt 값을 이용한다.

즉, 다른 정보는 전부 같지만 비밀번호는 다른 2개의 객체가 발생했고 첫 번째 객체를 저장 후 해당 객체의 비밀번호로 만든 세션 값을 가지고 있었는데 두 번째 form.save()에서 유저 객체의 비밀번호가 업데이트 되어 현재 세션 값과 유저 객체의 패스워드로 만든 세션 값이 일치하지 않아 장고는 유저가 제대로 로그인하지 않았다고 판단해서 Anonymous User 객체를 반환하게 된 것 같다.

사실 아직도 해결되지 않은 부분이 있다. 2번째 form.save()에서는 create 로직이 시작된 것이 아니라 update 로직이 실행되는 것이 정확한 지 모르겠다.


고찰

얕은 지식으로 인해 디버깅 시간이 굉장히 길어졌다고 생각한다. 당연히 unique 필드를 가지고 있는 User 객체로 인해 form.save()가 두번 실행될 수 없다고 생각부터 시작된 에러였다.

여태까지는 눈에 보이는 에러 페이지를 노출시켜주는 일반화 된 에러만 해결했었다.(그게 아니였어도 굉장히 쉬운 편에 속하는 문제들만 있어 금방 해결되었다.) 이번 과정을 통해 코드 구성에 대한 경각심을 기존보다 더 크게 가지게 되었다.

이번 디버깅을 통해 좋은 점은 장고 로그인에 대해서 조금 더 자세하게 알 수 있었다는 점이다. 추가적으로 궁금한 부분은 session이다. 로그인 인증에서 session을 이용하고 있으니 그 부분을 더 공부해 인증에 대해서 알아보고 싶다.


참고 및 도움

profile
비전공자가 고통받으며 개발합니다

0개의 댓글