사용자인증(2)

 

1. 로그인 기능 생성

가입하기 기능을 구현하면서 장고의 모델폼과 템플릿에 대해 많이 익숙해졌습니다. 로그인 기능을 구현할 때는 이보다는 복잡하지 않을 겁니다. 로그인은 어느 상황에서도 할 수 있도록 화면 상단(내비게이션바)의 오른쪽에 보이도록 할 겁니다. 로그인이 된 상태라면 당연히 로그아웃 버튼으로 보이도록 해야 합니다. 또한 로그인 화면 안에는 아직 가입되어 있지 않은 사용자들을 위해 회원가입 링크도 제공해야 합니다.

일단 뷰를 생성하고 url 라우팅을 추가합니다. 뷰는 장고 auth 프레임워크에서 제공하는 LoginView를 상속받아 구현할 예정입니다. 사실 대부분 설정만 바꾸게 될 것 같습니다.

# user/views.py

from django.contrib.auth import get_user_model
from django.contrib.auth.views import LoginView
from django.views.generic import CreateView

from user.forms import UserRegistrationForm


class UserRegistrationView(CreateView):   # 회원가입
    model = get_user_model()
    form_class = UserRegistrationForm
    success_url = '/article/'


class UserLoginView(LoginView):           # 로그인
    template_name = 'user/login_form.html'

    def form_invalid(self, form):
        messages.error(self.request, '로그인에 실패하였습니다.', extra_tags='danger')
        return super().form_invalid(form)    

로그인은 일단 화면이 나오게 하려면 LoginView 를 상속받고 template_name 변수를 정의해주면 됩니다. LoginView 는 뷰만 와 폼만 제공해주고, 템플릿은 제공해주지 않습니다. 기본값으로 registraion/login.html 로 설정되어 있는데 실제 찾아보면 존재하지 않는 파일입니다. 회원가입을 구현할 때 user 디렉토리에 템플릿을 저장했으니 로그인도 동일한 디렉토리에 저장하는 것으로 정했습니다. 또한 LoginViewFormView 의 서브클래스이기 때문에 login_form.html 이라고 했습니다. 이렇게 보니 회원가입 템플릿의 이름이 user_form.html 인데 좀 어색합니다. 이 부분은 나중에 수정하기로 하고 일단 urlpattern 에 뷰를 등록해줍니다.
인증에 실패한 경우 아무런 메시지도 없기 때문에 form_invalid 메소드를 오버라이드 해서 로그인에 실패할 경우 메시지를 출력하도록 기능을 추가했습니다.

# minitutorial/urls.py

# 생략 

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/login/', UserLoginView.as_view()),         # 로그인

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

이제 템플릿이 필요한데 회원가입할 때 사용했었던 템플릿을 그대로 복붙해 넣습니다. 수정이 필요한 부분은 title과 panel-heading 부분 그리고 버튼의 텍스트를 가입하기에서 로그인하기 로 변경하는 것 입니다.

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

<!-- 생략 -->


{% block title %}<title>로그인</title>{% endblock %}

{% block content %}
<div class="panel panel-default registration">
    <div class="panel-heading">
        로그인하기
    </div>
    <div class="panel-body">
        <form action="." method="post">
            {% csrf_token %}
            {% 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 %}

일단 이렇게 되면 회원가입과 중복되는 부분이 많은데 중복된 부분을 최소화할 수 있게 변경하는 것이 필요합니다. 추가로 고려해야 할 부분이 있을 지도 모르니 기능이 먼저 동작하는 것을 해결하겠습니다. 장고를 실행시키고 브라우저에서 http://localhost:8000/user/login/ 에 접속합니다.

LoginView 기본 템플릿 적용 후

화면으로 볼 때 그렇게 나쁘지 않은데 렌더링된 html 코드를 살펴보니 email 태그의 typeemail 로 되어 있지 않네요. 불편하진 않지만 브라우저 레벨의 email 유효성 검사를 하지 않고, 아마도 서버에서도 검증을 하지 않을 것 같습니다. 크게 중요하지 않은 문제이니 로그인을 성공시킨 후에 처리하도록 합니다.

LoginView 기본 템플릿 로그인 성공 후 Page Not Found

정상적인 이메일과 비밀번호를 입력하고 로그인하기를 눌렀는데 오류가 발생했습니다. 오류내용을 보니 /accounts/profile/ 로 접속을 시도했으나 존재하지 않는 페이지라는 오류입니다. 아마도 LoginView가 기본적으로 어떤 처리를 하고 정상적일 경우 해당 url로 이동을 하는 것으로 추정됩니다. LoginView의 코드를 간단히 살펴보겠습니다.

# django/contrib/auth/views.py

# 생략

class LoginView(SuccessURLAllowedHostsMixin, FormView):
    """
    Display the login form and handle the login action.
    """
    form_class = AuthenticationForm
    authentication_form = None
    redirect_field_name = REDIRECT_FIELD_NAME
    template_name = 'registration/login.html'
    redirect_authenticated_user = False
    extra_context = None

    @method_decorator(sensitive_post_parameters())
    @method_decorator(csrf_protect)
    @method_decorator(never_cache)
    def dispatch(self, request, *args, **kwargs):
        if self.redirect_authenticated_user and self.request.user.is_authenticated:
            redirect_to = self.get_success_url()
            if redirect_to == self.request.path:
                raise ValueError(
                    "Redirection loop for authenticated user detected. Check that "
                    "your LOGIN_REDIRECT_URL doesn't point to a login page."
                )
            return HttpResponseRedirect(redirect_to)
        return super().dispatch(request, *args, **kwargs)

    def get_success_url(self):
        url = self.get_redirect_url()
        return url or resolve_url(settings.LOGIN_REDIRECT_URL)

    def get_redirect_url(self):
        """Return the user-originating redirect URL if it's safe."""
        redirect_to = self.request.POST.get(
            self.redirect_field_name,
            self.request.GET.get(self.redirect_field_name, '')
        )
        url_is_safe = is_safe_url(
            url=redirect_to,
            allowed_hosts=self.get_success_url_allowed_hosts(),
            require_https=self.request.is_secure(),
        )
        return redirect_to if url_is_safe else ''

    def get_form_class(self):
        return self.authentication_form or self.form_class

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs['request'] = self.request
        return kwargs

    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_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        current_site = get_current_site(self.request)
        context.update({
            self.redirect_field_name: self.get_redirect_url(),
            'site': current_site,
            'site_name': current_site.name,
            **(self.extra_context or {})
        })
        return context

# 생략

원인을 찾아보기에 크게 복잡하지 않습니다. form_vaild 함수에서 auth_login 함수 실행 후 self.get_success_url() 메소드가 리턴하는 주소로 이동하는데 이때 self.get_success_url() 메소드는 3가지의 값들을 순서대로 검색하며 가장 먼저 검색된 값을 반환합니다. 아무런 설정을 하지 않으면 LOGIN_REDIRECT_URL 에 정의된 '/accounts/profile/' 로 반환하게 됩니다.

FormView 는 form 객체가 유효성이 모두 정상적일 경우 form_vaild 함수를 호출하고, 그렇지 않을 경우 form_invalid 함수를 호출합니다.

  • login redirection url 검색 순서
    1. 요청된 폼의 필드 중 next라는 이름을 가진 필드의 값. 빈 값인 경우 2번으로 패스
    2. url의 query parameter 중 next 라는 이름을 가진 값. 빈 값인 경우 3번으로 패스
    3. 설정파일에 설정된 LOGIN_REDIRECT_URL 변수로 설정된 값. (기본값: '/accounts/profile/')

이 조건을 근거로 로그인 후 여러분이 원하는 url로 이동시키기 위해서는 3가지의 조건 중 1가지 이상을 선택할 수 있습니다.

  • login redirection url 설정 방법
    1. form에 next라는 이름의 hidden 필드를 추가하고 '/article/' 값을 기본으로 세팅한다.
    2. form의 action 속성에 '/user/login/?next=/article/' 이라는 값을 세팅한다.
    3. 설정 파일에 LOGIN_REDIRECT_URL = '/article/' 이라고 설정한다.
    4. one more thing! get_success_url() 메소드를 오버라이드 해서 '/article/' 문자열을 반환한다.

현재 이 네가지 모두 사용해도 문제가 없습니다. 하지만 이 네가지 방법은 각각 사용해야 할 가장 좋은 케이스들이 있습니다. 누가 정해놓은 건 아니지만 장고로 개발된 대개의 서비스가 그렇게 사용하고 있다는 겁니다. 다시 4가지의 케이스를 나열해 볼테니 각각의 케이스가 몇번의 방법을 사용해야 좋을 지 생각을 해보세요.

  • 로그인 이후 redirection url 결정을 고려해야 할 케이스
    1. 대부분의 경우 사용자가 로그인 후 아무런 조건이 없을 때 이동할 페이지. 기본적인 REDIRECT URL
    2. 다양한 방식의 로그인을 제공해서 로그인 이후 이동할 페이지가 단순한 규칙으로 다를 경우. 예) 모바일과 PC 버전의 화면들을 각각 제공하며 url의 path에 따라 화면이 결정될 경우 /m/user/login/ => /m/article/, /user/login => /article/ 또는 다국어를 지원해서 언어별로 path를 구분하는 경우 /ko/user/login => /ko/article/, /en/user/login => /en/article/
    3. 로그인하기 전에는 redirect url을 알 수 없을 경우. 예) 로그인한 사용자의 권한레벨에 따라 슈퍼유저인 경우 admin 사이트로, staff 권한인 경우 대시보드 화면으로 이동, 로그인한 사용자의 연령이 20세 미만일 경우 특정화면으로 이동
    4. 어떤 화면으로 이동하려 했으나 인증된 사용자만 접근이 허락된 화면이어서 자동으로 로그인화면으로 이동한 경우 예) 로그인 하지 않은 사용자가 /admin/user/user/ 를 접근했으나 강제로 로그인 화면으로 이동되고, 로그인된 이후에 원래 사용자가 접근하려 했던 /admin/user/user/ 로 되돌려 보내야 하는 경우

이 외에도 케이스가 더 다양하겠지만 이 정도가 실무적으로 가장 빈번하게 발생하는 케이스인 듯 합니다. 각 케이스마다 가장 괜찮은 방법을 찾는 것은 상황마다 조금씩 다를 수 있고 개발자마다 성향이 조금 다를 수 있습니다. 저의 경우 케이스별 방법을 매칭해보니 이렇습니다. 각 케이스별로 한 가지만 선택해야 하는 경우를 가정했습니다.

  1. => 3번 방법
  2. => 2번 방법
  3. => 4번의 get_success_url 메소드를 케이스 별 처리하도록 오버라이딩
  4. => 2번 방법

저의 선택에 나름대로 규칙이 있습니다. 가장 선호하는 방법은 3, 2, 1, 4 순서입니다. 4번(get_success_url 메소드 오버라이딩)은 장고에서 기본적으로 제공하는 루틴을 무시하고 재정의 하는 것이니 코드의 일관성을 해치는 방법입니다. 4번 방법은 피할 수 있으면 피하세요. ddong이라고 생각하세요. 3번 방법(설정파일에 LOGIN_REDIRECT_URL 변수 설정)은 무조건 설정하세요. 그리고 예외적인 케이스는 전부 2번 방법(url에 query 파라미터 추가)으로 처리합니다. 만일 2번 방법보다 1번의 방법이 코드가 효율적이거나 url로 redirect url이 노출되는 것이 싫은 경우에만 1번을 사용합니다.

이해가 되셨는 지 모르겠습니다. 글을 업로드 하기 전 매번 2번이상 내용을 재확인하는데 읽을 때마다 보는 분들이 불편하겠구나 생각되는 부분들이 있습니다. 아래 gitalk 게시판이 있으니 질문을 남겨주세요. 게시판에 남겨주시면 다른 분들에게도 도움이 됩니다.

결론은 3번 방법으로 설정파일에 LOGIN_REDIRECT_URL = '/article/' 를 추가 하겠습니다. 예외적인 상황이 생기면 2번 방법으로 처리하겠습니다. 이 한줄 설명하는 게 이렇게나 힘듭니다.

# minitutorial/settins.py

# 생략

STATIC_URL = '/static/'

AUTH_USER_MODEL = 'user.User'

LOGIN_REDIRECT_URL = '/article/'  # 로그인이 되면 /article/로 이동

이제 로그인 해보시면 정상적으로 게시글 목록 화면('/article/')으로 이동이 됩니다. 한가지 아쉬운 것은 로그인된 상태인지 아닌지 확인이 안된 다는 것입니다. 아까 말한대로 내비게이션바 오른쪽에 로그아웃 링크를 추가해서 로그인된 상태일 때는 로그아웃으로 보이고, 로그인이 안 된 상태에서는 로그인으로 보이도록 만들어 봅니다. 단지 상황에 따라 html만 변경되면 되는 것이니 템플릿만 수정하겠습니다.

<!-- bbs/templates/base.html -->


<!-- 생략 -->

{% block header %}
    <nav class="navbar navbar-default">
        <div class="container-fluid">
            <div class="navbar-header">
                <a class="navbar-brand" href="/article/">게시글 목록</a>
            </div>
            <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/login/">로그아웃</a>
                        {% else %}
                        <a href="/user/login/">로그인</a>
                        {% endif %}
                    </li>
                </ul>
            </div>
        </div>
    </nav>
    {% if messages %}
        {% for message in messages %}
        <div class="alert alert-{{ message.tags }} alert-dismissible" role="alert">
          {{ message }}
        </div>
        {% endfor %}
    {% endif %}
    {% endblock header %}

<!-- 생략 -->


장고의 미들웨어 중에 'django.contrib.sessions.middleware.SessionMiddleware''django.contrib.auth.middleware.AuthenticationMiddleware' 는 사용자 인증에 관한 처리를 담당합니다. SessionMiddleware 는 로그인함수(auth_login)를 통해 생성된 세션을 관리합니다. 세션이 유효한지 만료되었는지 판단을 해서 유효한 경우에는 request 객체에 session 이라는 변수에 세션정보를 저장합니다. AuthenticationMiddlewarerequest.session 값을 가지고 어떤 사용자인지 확인을 합니다. 확인된 사용자는 request.user 객체에 해당 사용자의 모델 인스턴스를 저장합니다.

우선 로그인과 로그아웃 링크만 보이도록 만들었고 모두 로그인 페이지로 이동하도록 링크를 설정했습니다. 버튼들이 정상적으로 작동하는 지 클릭해서 확인해봅니다. 언제나 클릭을 하면 로그인 화면으로 이동하는데 로그아웃 링크를 눌러도 로그인버튼으로 변경되지 않습니다. 로그아웃 기능을 구현하면 해결된 문제이니 지금은 로그인의 나머지 기능에 집중합니다.

LoginView 기본 템플릿 로그인 로그아웃 링크 추가 후

로그인 기능은 구현했지만 지난번에 만들었던 게시판 앱에 아직 인증된 사용자만 접근허용 기능을 연동시키지 않아 로그인을 하지 않은 상태에서도 어떠한 화면으로든 접근할 수 있습니다. 현재 게시글을 보는 건 비공개로 설정하지 않았지만 글을 쓸 때와 수정할 때는 인증된 사용자만 가능하도록 변경할 것 입니다.

장고의 auth 프레임워크에는 인증된 사용자만 뷰의 핸들를 호출할 수 있도록 하는 기능이 이미 구현되어 있습니다. 일반 웹과 관련된 기능은 대부분 구현되어 있습니다. 비지니스 로직에 맞게 잘 조립해 사용하시면 됩니다.

FBV에서는 login_required 라는 데코레이터를 핸들러에 wrapping 해주면 되는데 CBV에서는 LoginRequiredMixin 믹스인을 뷰에 추가해주면 됩니다. 단 로그인이 되어 있지 않은 경우에 로그인 url로 이동시켜야 하는데 login_required 데코레이터에서는 login_url 이라는 파라미터에 전달하면 되고, LoginRequiredMixin 에서는 login_url 이라는 클래스변수를 선언해주거나 설정파일에 LOGIN_URL 변수에 url을 정의하면 됩니다. 복잡하지 않으니 일단 코드를 보시면 됩니다. 로그인 url은 프로젝트 내에서 공통적으로 사용하는 것이니 기본적으로 설정파일에 변수를 추가하고 뷰에도 클래스변수로 추가하도록 하겠습니다.

# minitutorial/settins.py

# 생략

STATIC_URL = '/static/'

AUTH_USER_MODEL = 'user.User'

LOGIN_REDIRECT_URL = '/article/'

LOGIN_URL = '/user/login/'
# bbs/views.py
from django.conf import settings

# 생략

class ArticleCreateUpdateView(LoginRequiredMixin, TemplateView):
    login_url = settings.LOGIN_URL       # 설정파일의 값으로 설정
    template_name = 'article_update.html'
    queryset = Article.objects.all()
    pk_url_kwargs = 'article_id'

# 생략

현재는 로그인된 상태이니 아무 문제없이 접속이 될 겁니다. 아직 로그아웃 기능을 구현하지 않았기 때문에 꼼수를 사용합니다. 장고는 기본적으로 세션정보를 데이터베이스에 저장합니다. 아직은 개발단계이므로 무식하게 모든 세션데이터를 삭제해도 상관이 없습니다. 쿠키에서 sessionid 값을 얻는 방법을 아신다면 해당 sessionid 값과 django_session 테이블의 session_key 값을 비교해서 동일한 레코드만 삭제하셔도 괜찮습니다. 저는 크롬브라우저의 개발자도구를 이용해서 쿠키에 있는 sessionid 알아냈고, 제 session 정보만 삭제했습니다. 그냥 쿠키의 sessionid 정보를 삭제하셔도 됩니다.

sqlite> DELETE FROM django_session WHERE session_key = 'eyar6aan2nmmuelypubu1r1exvht....';

이제 새로고침을 하면 로그아웃이 로그인으로 변경되어 있을 겁니다. 또 /article/create/ 주소로 접속하면 로그인 화면으로 이동되고 url에 /user/login/?next=/article/create/처럼 query 파라미터가 추가되어 있는 것을 확인할 수 있습니다.

LoginView 세션 로그인되지 않은 상태에서 게시글 작성 화면으로 이동했을 때

이제 로그인기능이 정상적으로 동작한다는 것을 확인했습니다. 로그인은 특별히 어려운 점이 없었던 것 같은데 인증 매카니즘을 구체적으로 설명하지 않았던 것 같습니다.

이미 장고에 많은 것들이 구현되어 있고, 어떤 기능은 누군가에 의해 개발되어 오픈소스로 공개되어 있는 기능도 많이 있습니다. 결국 장고를 잘 다룬다는 것은 이미 만들어져 있는 것들을 잘 찾아서 그것들을 적절히 연동하는 것 입니다. 이런 능력은 특별한 건 아니고 용기있게 소스코드를 들여다 보다보면 길러지는게 됩니다. 시간이 웬수 열심히 남들이 만든 기술들을 보고 이해하고 따라해보세요.

이제 아까 미뤄뒀던 email 필드를 정상적인 email 형식의 input 태그로 변경하겠습니다. 굳이 하지 않아도 문제가 없지만 email 형식으로 변경하면 모바일에서 자판이 email 용으로 나타나는 장점이 있습니다. 템플릿은 그대로 두고 폼클래스를 수정해서 CharField로 선언된 부분을 EmailField로 변경해줍니다.

폼부터 정의할 건데 LoginView 에서 form_class 로 설정된 AuthenticationFrom 을 상속받아 LoginForm 이라는 폼클래스를 정의하고, username 이라는 클래스를 EmailField로 변경하고 내부의 widget을 EmailInput으로 변경합니다.

# user/forms.py

from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from django.forms import EmailField


class UserRegistrationForm(UserCreationForm):

    class Meta:
        model = get_user_model()
        fields = ('email', 'name')


class LoginForm(AuthenticationForm):
    username = EmailField(widget=forms.EmailInput(attrs={'autofocus': True}))

FieldWidget의 역할이 궁금하실 수 있는데 Field는 유효성 검증과 위젯의 기능들을 호출하는 역할을 합니다. Widget은 필드의 실제 렌더링과 관련된 역할을 합니다. Widget에 따라 input_type 클래스변수에 email 인지 text 인지 password 인지가 정의되어 있습니다.

이제 새로 생성된 LoginFormUserLoginView 에 설정해주면 되는데, LoginView 처럼 form_class 클래스변수에 정의해서 오버라이드 하는 방법도 있으나 권장하는 방법은 authentication_form 이라는 클래스변수에 LoginForm을 설정하는 것 입니다. 강제사항은 아니나 LoginForm 내부적으로 authentication_form 을 먼저 확인하고 없으면 form_class를 이용하도록 되어 있습니다. 즉 커스터마이징 할 거라면 authentication_form 를 사용하라는 원 제작자의 의도가 있습니다. 왜 이렇게 하기를 원하는 지 저의 수준으로는 알 수 없습니다만 프레임워크는 그 제작자가 설계한 의도대로 따라 주는게 가장 문제가 생기지 않는 방법입니다.

# user/views.py

from django.contrib.auth import get_user_model
from django.contrib.auth.views import LoginView
from django.views.generic import CreateView

from user.forms import UserRegistrationForm, LoginForm


class UserRegistrationView(CreateView):
    model = get_user_model()
    form_class = UserRegistrationForm
    success_url = '/article/'


class UserLoginView(LoginView):
    authentication_form = LoginForm
    template_name = 'user/login_form.html'

    def form_invalid(self, form):
        messages.error(self.request, '로그인에 실패하였습니다.', extra_tags='danger')
        return super().form_invalid(form)

장고가 다시 재시작되면 로그인화면을 접속해서 확인해보세요. 정상적으로 이메일 형식의 input 태그로 변경되었음을 알 수 있습니다.

classDiagram
Form <|-- AuthenticationForm
AuthenticationForm <|-- LoginForm

AuthenticationForm : username = UsernameField(widget=TextInput)
AuthenticationForm : password = CharField(widget=PasswordInput)
AuthenticationForm : clean()
AuthenticationForm : confirm_login_allowed()
AuthenticationForm : get_user()
AuthenticationForm : get_invalid_login_error()

LoginForm : username = EmailField(widget=EmailInput)

SuccessURLAllowedHostsMixin <|-- LoginView
FormView <|-- LoginView

LoginView <|-- UserLoginView

LoginView : form_class = AuthenticationForm
LoginView : authentication_form = None
LoginView : redirect_field_name = 'next'
LoginView : template_name = 'registration/login.html'
LoginView : redirect_authenticated_user = False
LoginView : get_success_url()
LoginView : get_redirect_url()
LoginView : get_form_class()
LoginView : get_form_kwargs()
LoginView : form_valid()
LoginView : get_context_data()


UserLoginView : authentication_form = LoginForm
UserLoginView : template_name = 'user/login_form.html'

2. 세션관리

로그인정보를 삭제할 때 sessionid 값을 브라우저의 쿠키에서 삭제만 해도 사라진다고 설명 했었는데, 반대로 sessionid 값을 다른 브라우저에 세팅을 하면 어떻게 될까요? 현재로서는 해당세션이 동일하게 복제가 됩니다. 예를 크롬으로 로그인한 뒤 쿠키의 sessionid 값을 복사해서, safari 브라우저 sessionid 라는 쿠키를 저장하게 되면 safari 브라우저에서도 로그인된 것처럼 사용하실 수 있습니다. 이러한 헛점들이 있는데 장고에서는 최대한 다양한 옵션으로 악의적인 행위를 차단하고 있습니다.

이렇게 쿠키값이 유출이 된다면 자신만의 개인정보가 누출되거나, 자신의 권한으로 제3자가 내가 원하지 않는 어떠한 행위를 할 수 있습니다. 그래서 장고의 SessionMiddleware 에서는 세션관리를 위한 다양한 옵션을 제공합니다. 모든 것을 다 소개할 필요는 없고 자세한 사항은 장고 공식문서에 설명하고 있으니 여기서는 자주 사용하는 몇 가지만 소개하고 실제 적용해보겠습니다.

세션 미들웨어

먼저 세션이 무엇인지 알아야 하는데요. 세션은 로그인한 사용자에게만 발급하는 일종의 표딱지 사용권입니다. 사용자에게는 이 사용권의 sessionid 만 알려주고(사용자 쿠키에 저장합니다.), 장고는 해당 sessionid 를 누구에게 발급해줬는지 세션이라는 객체에 사용자의 인증정보를 저장합니다. 이 세션객체는 기본적으로 데이터베이스에 저장되도록 되어 있지만 다양한 방식으로 관리할 수 있습니다.

세션 미들웨어는 세션정보를 어디에 어떤 방식으로 저장하는지에 대한 부분만 관여합니다. 세션에 어떤 데이터가 있는지는 관심이 없습니다. 세션 미들웨어는 여러가지 세션 백엔드 중 하나를 선택해서 백엔드에게 실제 저장기능을 위임합니다.

세션 백엔드 모듈이름 기능
django.contrib.sessions.backends.db 데이터베이스에 저장하는 백엔드
django.contrib.sessions.backends.cache 캐시에 저장하는 백엔드
django.contrib.sessions.backends.cache_db 캐시와 데이터베이스를 병행하는 백엔드
django.contrib.sessions.backends.file 파일에 저장하는 백엔드
django.contrib.sessions.backends.signed_cookie 쿠키에 저장하는 백엔드

쿠키에 세션을 저장하는 것은 사용자에게 모든 정보를 노출하는 것이기 때문에 그다지 좋지 않습니다. 또한 쿠키의 크기가 커지니 네트워크 부하에도 영향이 있습니다. 사용자에게 세션데이터를 노출해야만 하는 경우에만 사용합니다. 세션데이터에 민감한 내용이 들어있다면 사용해서는 안됩니다.

가장 흔하게 사용하는 방법은 데이터베이스 백엔드와 캐시 백엔드입니다. 데이터베이스는 아주 큰 용량이 비교적 빠른 성능을 보장하므로 범용적으로 사용하기에 좋습니다. 캐시 백엔드는 장고의 전역 캐시 백엔드에 세션데이터를 저장합니다. 캐시 백엔드가 메모리 기반이라면 캐시서버가 리부팅될 때 모든 데이터가 초기화 될 수 있으니 메모리 기반의 캐시 백엔드를 사용한다면 데이터베이스와 병행하도록 설정해야 합니다.

더 자세히 알고 싶다면 장고 공식문서를 참고하세요.

세션미들웨어는 사용자가 요청을 할 때마다 쿠키의 sessionid 를 확인 후 세션 백엔드 세션데이터를 불러와 request.session 객체에 저장합니다. 만일 세션데이터가 유효하지 않다면(expire_date, path 등) 빈 세션데이터만 저장합니다. request.session 객체를 접근할 수 있는 어디서든 객체에 추가적인 데이터를 저장하거나 삭제, 수정이 가능합니다. 대게 빈번하게 사용하는 사용자의 데이터를 캐시의 목적으로 저장하거나 커스텀 인증 미들웨어를 사용할 때 세션객체를 수정합니다. 그 외에 임의로 삭제, 수정은 하지 않도록 주의하셔야 합니다.

세션데이터는 사용자가 매 요청마다 불러와야 하는 값이고 응답할 때까지 메모리에 저장하고 있어야 하기 때문에 너무 많은 데이터를 가지고 있으면 서버의 가용성에 지장이 생길 수 있습니다. 대부분의 사용자가 매번 필요한 데이터일 경우에만 세션객체에 추가하셔야 합니다.

안전한 세션 관리 방법

아까 전에 설명한대로 sessionid 가 누출됐을 경우도 안전하지 못한 상황이 되는데 이 문제를 어떻게 해결하는 것이 좋을까요? 장고에서 여러가지 방법을 제공합니다만 기본적으로 secure 쿠키sessionid 를 저장하는 방법과 session 의 유효기간을 설정해서 유효기간이 지난 이후에는 세션데이터를 무효화 시키는 기능을 제공합니다.

secure 쿠키는 javascipt로는 세션값을 불러올 수 없고, 브라우저의 개발자도구를 열어 확인하거나, 브라우저를 바이러스 또는 activex 등으로 메모리의 값을 찾는 것 외에는 sessionid 값을 알 수 없게 하는 것 입니다. 즉 sessionid 를 브라우저를 실제로 보고 있는 사람 또는 서버 외에서는 볼 수 없게 하는 것입니다. secure 쿠키설정은 설정파일에 SESSION_COOKIE_SECURE 변수에 True로 설정하면 활성화가 되는데 기본적으로 True가 설정되어 있습니다. 꼭 필요한 경우 외에는 False로 변경하지 마세요. 반드시 False로 변경해야 하는 경우는 없습니다.

세션의 유효기간이 설정되면 쿠키도 동일한 유효기간이 설정됩니다. 브라우저에서는 유효기간이 지난 쿠키는 자동삭제를 합니다. 만일 쿠키를 조작해서 유효기간을 충분히 사용가능하게 변경하더라도 서버에 저장된 유효기간으로 유효성을 검사하기 때문에 유효기간이 짧으면 짧을 수록 보안강도가 높아집니다. 문제는 세션의 유효기간이 지나면 로그인되지 않은 상태로 인식하기 때문에 다시 로그인을 해줘야 합니다. 유효기간 설정은 설정파일에 SESSION_COOKIE_AGE 값에 초단위의 기간을 설정하면 됩니다. 기본값으로 1209600(2주)가 설정되어 있으니 서비스 정책에 맞게 수정하시면 됩니다. 테스트 삼아 SESSION_COOKIE_AGE = 10으로 설정하고 재로그인을 해봅니다. 10초 이후에 다시 접근하면 로그인이 풀려있게 됩니다.

또다른 간편한 방법으로 쿠키의 세션id 이름을 변경하는 것입니다. 설정파일의 SESSION_COOKIE_NAME 값을 누군가 쉽게 알 수 없는 이름으로 변경하면 악의적인 목적을 가진 사람에게는 조금 불편함을 줄 수 있습니다. 이 방법은 보안강도로 표현하면 아주 미세하게 효과를 줍니다. 보통 보안목적보다는 sessionid 라는 이름이 중복이 되거나 어쩔 수 없이 변경해야 할 경우에 사용합니다.

이 외에 강력한 방법으로 매 요청 때마다 request.session 객체의 cycle_key() 메소드를 호출하는 겁니다. cycle_key() 메소드가 호출될 때마다 sessionid 가 변경되고 변경된 값이 쿠키에 저장이 됩니다. sessionid 가 노출되었더라도 sessionid 를 악의적인 목적으로 사용하기 전에 또다른 요청을 한다면 이전의 sessionid 값은 유효하지 않은 것이 됩니다. 하지만 매 요청마다 세션 백엔드가 sessionid 를 다시 저장해야 하기 때문에 서버의 가용성이 조금 떨어진다는 것이 문제입니다. 서버의 성능보다는 안정성이 중요하다면 이 방법을 사용해도 좋습니다.

2. 인증관리

인증이라는 것은 정당한 절차를 통해 접속했는 지 확인하고 접속자가 누구인지를 증명하는 절차를 의미합니다. 세션이 증명서라면 증명서에 있는 인증은 증명서를 생성하는 절차를 담당하고, 누군가 증명서를 들고 왔을 때 누구의 증명서인 지를 확증하는 것입니다. 장고의 인증은 기본적으로 데이터베이스를 기반으로 하고 있으나 추가적인 라이브러리를 설치하거나 별도의 모듈을 구현한다면 소셜로그인과 같은 외부의 api를 연동해서 인증을 하고 사용자의 정보를 공개된 범위 내에서 제공받을 수도 있습니다.

로그인을 하면 세션 미들웨어가 생성한 request.session 객체에 세션정보를 저장합니다. 이 때 저장한 세션정보의 내용은 세션 백엔드에 의해 자동으로 저장됩니다. 즉 로그인의 최종목적은 사용자의 세션객체를 request.session 객체에 저장하는 것입니다.

인증 미들웨어

graph TD
    user((사용자)) ==> django[장고 http 서버] 
    django ==> session[세션미들웨어] 
    session -- 세션객체 불러와 --- session_backend(세션 백엔드)
    session == request.session ==> auth[인증미들웨어] 
    auth -- 사용자정보 불러와 --- auth_backend(인증 백엔드)
    auth == request.user ==> view{뷰}
    view -.-> auth 
    auth -.-> session 
    session -. 세션저장, 쿠키설정 .-> django
    django -.-> user

    caption(인증 과정)
    style caption fill: #ffffffff

세션 미들웨어가 sessionid 를 가지고 request.session 객체를 저장하면 인증 미들웨어(AuthenticationMiddleware)는 이 세션객체의 내용을 확인하고 사용자 정보를 불러와 request.user 객체에 저장합니다. 실질적으로 미들웨어가 직접 사용자의 신원을 확인하지 않고 인증 백엔드에게 위임합니다. 인증 백엔드는 사용자 정보를 가져올 때 세션객체의 내용과 사용자 정보의 내용을 비교하여 세션정보가 사용자 정보와 일치하지 않을 경우 request.user 객체에 AnonymousUser 객체를 저장합니다.

정확히 설명하면 사용자 모델을 매번 불러오는 것이 아니라 미들웨어는 일단 SimpleLazyObject 객체를 반환합니다. request.user = SimpleLazyObject(lambda: get_user(request)) request.user 가 가리키는 SimpleLazyObject 객체는 처음에는 빈 객체이지만 request.user 객체에서 특정 속성(is_authenticated, is_superuser, is_staff 등)을 접근할 때 실제 데이터를 불러와 캐싱합니다. 즉 request.user 객체의 속성값을 읽으려 하기 전까지는 장고(인증 미들웨어)는 사용자가 누구인지 모르고 세션객체의 내용만 알 수 있습니다. 이렇게 설계된 이유는 모든 요청 때마다 사용자 정보를 불러온다면 사용하지도 않을 사용자 정보 때문에 서버 자원을 소모하지 않게 하기 위함입니다. SimpleLazyObject 는 인증 외의 많은 부분에서도 유용하게 사용할 수 있으니 사용법을 반드시 기억해주시길 바랍니다.

장고에서 기본적으로 세션객체에 _auth_user_id, _auth_user_backend, _auth_user_hash 이 세가지 정보를 딕셔너리 형태로 저장합니다.

key 데이터
_auth_user_id 사용자 정보 id
_auth_user_backend 로그인할 때 사용한 인증 백엔드
_auth_user_hash 사용자정보 테이블에 저장된 패스워드값의 해시값

인증미들웨어는 인증 백엔드를 통해 사용자를 식별하는 하도록 인증기능을 위임합니다. 소셜로그인 같은 외부의 인증 api를 사용하는 경우는 각 api 별로 백엔드가 달라질 수 있습니다. 장고는 기본적으로 데이터베이스의 사용자 정보 모델을 기반으로 인증을 처리하는 백엔드를 제공합니다. 모델 인증 백엔드는 _auth_user_id 를 가지고 사용자가 누구인지 식별하고 사용자 모델의 객체를 request.user 객체에 저장합니다.

그리고 사용자의 요청에 의해 비밀번호가 변경되었을 경우 변경된 비밀번호(데이터베이스에 저장된 값)의 해시값과 _auth_user_hash 값을 비교하면 같지 않을테니 이전 세션은 유효하지 않게 됩니다. 즉 비밀번호를 변경하면 이전의 세션은 유효하지 않고 로그아웃상태로 전환됩니다.

안전한 인증 방법

로그인은 위에서 설명드린 대로 장고의 auth 프레임워크를 이용하시면 됩니다. 위에서 설명드린 방식 말고 특정한 케이스에 로그인폼을 통하지 않고도 인증을 하거나 재인증을 하기 원할 때 auth 프레임워크의 login 함수를 사용하시면 됩니다. 아무리 장고에서 안전하고 정확하게 인증을 한다하더라도 장고 앱으로 데이터가 전송되기 전인 네트워크에서 이동하는 과정 중에 노출된 사용자 정보는 장고가 보호해줄 수 없습니다. 예를 들어 로그인 할 때 이메일과 비밀번호를 입력하는데 http에서는 입력한 문자(이메일, 비밀번호 등) 그대로 네트워크를 통해 전송이 됩니다. 잘 알려진대로 공용 네트워크에서는 동일한 네트워크 안에 있는 누군가에게 해당 내용이 노출이 될 여지가 많이 있습니다. 그래서 어떠한 안전한 인증 기능을 제공하기 이전에 반드시 https를 제공해야 합니다. 할 수만 있다면 https를 반드시 사용하시기 바랍니다.

https를 제공할 수 없다면 소셜로그인 기능을 연동하는 것도 좋은 방법입니다. 소셜로그인은 대부분(제가 아는 한 모든) 프로바이더들이 https를 제공합니다. 다만 반드시 프로바이더가 정한 규칙 또는 oauth의 규칙을 준수하셔서 연동하시기 바랍니다.

인증된 사용자에게만 접속을 허용하기 뷰클래스에 LoginRequiredMixin 이 추가(CBV)되었거나 핸들러 함수가 login_required 데코레이터로 wrapping 을 합니다. 이것들은 내부적으로 requests.user.is_authenticated 값을 비교합니다. 여러분들도 별도의 프로세스로 사용자의 인증여부를 확인해야 하는 경우 가급적 requests.user.is_authenticated 의 값을 이용하시면 오류의 가능성이 현저히 줄어듭니다.

http만 제공하는 웹서비스는 손에 면도칼을 든 사람들이 새로 왁스 칠한 마룻바닥 위에서 빠른 속도로 춤을 추는 것과 같다. swarf00, 나는야 춤추는 개발자...