Django 인증 & 인가 구현하기

강두연·2020년 11월 14일
2

Django

목록 보기
2/3
post-thumbnail

웹 사이트에서 가장 기본이며 가장 자주 구현되는 API는 아마도 인증과 인가 이다.
Private한 API는 물론, Public한 API도 기본적인 인증과 인가를 요구한다.
인증(authentication)과 인가(authorization)의 차이를 알아보고 장고에서 구현해 보자.

인증(Authentication)

인증이란 유저의 identification을 확인하는 절차이다. 즉, 유저의 아이디와 비밀번호를 확인하는 절차라고 할 수 있다.
위 이미지와 같이 사용자가 입력한 정보를 가지고 이 사용자가 누구인지, 우리 서비스의 회원인지를 확인하는 것이 인증이다.

즉, 인증을 하기 위해서는 사전에 유저의 아이디와 비밀번호와 같은 정보들을 생성하고 저장할 수 있는 기능이 필요하다.

로그인 절차:

  • 유저 아이디와 비밀번호 생성
  • 유저의 비밀번호를 암호화 해서 DB에 저장
  • 유저 로그인 -> 아이디와 비밀번호가 입력됨
  • 유저가 입력한 비밀번호를 암호화 한후에 DB에 저장된 비밀번호와 비교
  • 일치하면 로그인 성공
  • 로그인 성공하면 access token을 클라이언트에게 전송
  • 유저는 로그인 성공후 다음부터는 access token을 첨부해서 서버에 요청을 보냄

유저 비밀번호 암호화

유저의 비밀번호를 그대로 DB에 저장하면 DB가 해킹 당했을 때 유저의 비밀번호가 그대로 노출되고 해킹뿐만 아니라 내부 개발자나 인력이 유저들의 비밀번호를 볼 수 있기 때문에 유저의 비밀번호는 암호화 해서 저장해야 한다.

단방향 해쉬 함수(One-way hash function)

일반적으로 비밀번호 암호화 하는데 사용되는 함수이다.
원본 메세지를 변환하여 암호화된 메세지인 다이제스트(digest)를 생성하는데 원본 메세지를 알면 암호화된 메세지를 구하기는 쉽지만 암호화된 메세지로는 원본 메세지를 구할 수 없기 때문에 단방향성(one-way)이라고 한다.

단방향 해쉬 함수를 사용하면 비슷 원본메세지로 암호화를 하더라도 완성된 digest는 완전히 다른 값을 보여주는데 이러한 효과를 avalance라고 하고 비밀번호 해킹을 어렵게 만드는 요소중 하나이다.

단방향 해쉬 함수에는 치명적인 단점이 하나 있다.
바로, 원본메세지가 같으면 똑같은 값만 만들어 낸다는 점이다.

이미지를 보면 똑같은 'password'라는 원본의 메세지를 단방향 해쉬를 하면 똑같은 값으로 변환되는 것을 볼 수 있다.

사실 해시 함수는 패스워드를 저장하기 위해 설계된 것이 아니라 짧은 시간에 데이터를 검색하기 위해 설계된 것이다.

Bcrypt

앞서 말한 단방향 해쉬 함수의 취약점을 해결하기 위해 일반적으로 2가지 기법이 사용되는데

  • salting: 실제 원본 메세지 이외의 추가적으로 랜덤 데이터를 더해서 해시값을 계산하는 방법
  • key stretching: 단방향 해쉬값을 계산 한 후 그 해쉬값을 또 해쉬하고 이를 여러번 반복하는 방법

salting과 key stretching을 모두 구현한 해쉬 함수중 가장 널리 사용되는 것이 bcrypt이다.

In [40]: import bcrypt

In [41]: bcrypt.hashpw(b"secrete password", bcrypt.gensalt())
Out[41]: b'$2b$12$.XIJKgAepSrI5ghrJUaJa.ogLHJHLyY8ikIC.7gDoUMkaMfzNhGo6'

In [42]: bcrypt.hashpw(b"secrete password", bcrypt.gensalt()).hex()
Out[42]: '243262243132242e6b426f39757a69666e344f563852694a43666b5165445469397448446c4d366635613542396847366d5132446d62744b70357353'

같은 원본의 메세지를 해쉬 하였지만 전혀 다른 값이 나오는 게 보여진다.

JWT(JSON Web Tokens)

앞서 언급했듯이 유저가 로그인에 성공한 후에는 Access token 이라고 하는 암호화된 유저 정보를 첨부해서 response를 보내게 된다.
이렇게 만들어진 토큰은 Authorization 에서도 사용된다.

  • 요청
POST /auth HTTP/1.1
Host: localhost:5000
Content-Type: application/json

{
    "username": "joe",
    "password": "pass"
}
  • 응답
HTTP/1.1 200 OK
Content-Type: application/json

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eSI6MSwiaWF0IjoxNDQ0OTE3NjQwLCJuYmYiOjE0NDQ5MTc2NDAsImV4cCI6MTQ0NDkxNzk0MH0.KPmI6WSjRjlpzecPvs3q_T3cJQvAgJvaQAPtk1abC_E"
}

JWT의 특징은 바로 위에 언급했던 단방향 해쉬와는 다르게 원본 메세지를 구하는 즉, 복호화가 가능하다는 것이다.
response에 담긴 access_token을 클라이언트 사이드에서 session, local 또는 쿠키 같은 저장장치에 저장하여 로그인이나 사용자의 권한을 확인해야하는 API등을 요청할 때 마다 토큰을 request 헤더에 담아서 같이 보내고 서버에서는 해당 토큰을 복호화하여 해당 유저에게 알맞는 권한이 있는지 확인하고 그에 알맞는 결과를 다시 보내준다.

import jwt #패키지명은 pyjwt이지만 임포트할때의 이름은 jwt입니다.

SECRET = 'secret' #'랜덤한 조합의 키' 예제이므로 단순하게 'secret'이라고 하겠습니다.

access_token = jwt.encode({'id' : 1}, SECRET, algorithm = 'HS256')
print(access_token)
>>> b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MX0.-xXA0iKB4mVNvWLYFtt2xNiYkFpObF54J9lj2RwduAI'
header = jwt.decode(access_token, SECRET, algorithm = 'HS256')
print(header)
>>>{'id': 1}

위 예제의 코드를 보면 {'id': 1} 이라는 하나의 키 밸류 페어를 토큰화 시킨후 복호화를 했을 때 다시 원본 메세지를 얻을 수 있다는 걸 볼 수 있다.

인가(Authorization)

인가(authorization)란 유저가 요청하는 request를 실행할 수 있는 권한이 있는 유저인가 확인하는 절차 이다. 인스타그램으로 예를 들면, 로그인하지 않았는데 게시물을 등록하거나 삭제 할 수는 없을 것이고 이런 것들을 확인하는 절차이며 또 다른 예로는 요즘 유행하는 구독서비스 구현의 베이스가 되는 것이 바로 인가이다.
구현하는 방법중에 가장 대표적인 것이 JWT(JSON Web Tokens)이다.

Authorization 절차

  1. Authentication 절차를 통해 access token을 생성한다. access token에는 유저 정보를 확인할 수 있는 정보가 들어가 있어야 한다 (가장 흔하게 쓰이는 것이 user id).
  2. 유저가 request를 보낼때 access token을 첨부해서 보낸다.
  3. 서버에서는 유저가 보낸 access token을 복호화 한다.
  4. 복호화된 데이터를 통해 user id를 얻는다.
  5. user id를 사용해서 database에서 해당 유저의 권한(permission)을 확인하다.
  6. 유저가 충분한 권한을 가지고 있으면 해당 요청을 처리한다.
  7. 유저가 권한을 가지고 있지 않으면 Unauthorized Response(401) 혹은 다른 에러 코드를 보낸다.

Django에서 인증,인가 구현

Authentication

#my_project/user/views.py - UsersView(View):
    def post(self, request):
        data           = json.loads(request.body)
        valid_email_re = '^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w+$'

        try:
            new_email       = data['email']
            new_password    = data['password']
        except keyerror:
            return jsonresponse({'message': "key_error"}, status = 400)

        #email validation using re
        if not re.search(valid_email_re, new_email):
            return jsonresponse({'message': "invalid email"}, status = 400)

        #password validation
        if not len(new_password) >= 8:
            return jsonresponse({'message': "invalid password"}, status = 400)

        #duplacted value validation
        exist_user = user.objects.filter(email=new_email)
        if exist_user:
            return jsonresponse({'message': "name, phone or email is already exists"}, status = 400)

        hashed_password = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
        user = user.objects.create(
            email         = new_email,
            password      = hashed_password
        )

        return jsonresponse({'message': "success"}, status = 200)

위 코드는 회원가입 엔드포인트를 구현한 것으로, 가입 할때 입력받은 패스워드를 bcrypt를 활용하여 암호화 한뒤 DB에 저장한다.

#my_project/user/views.py - LoginView(View):
    def post(self, request):
        data = json.loads(request.body)
        
        try:
            input_account  = data['account']
            input_password = data['password'].encode('utf-8')
        except Exception as error_msg:
            return JsonResponse({'message': 'KEY_ERROR'}, status = 400)

        try:
            user = User.objects.get(email=input_account)
        except Exception:
            return JsonResponse({'message': 'INVALID_USER'}, status = 400)

        if bcrypt.checkpw(input_password,user.password.encode('utf-8')):
            SECRET = SECRET_KEY
            access_token = jwt.encode({'id':user.id}, SECRET, algorithm=ALGORITHM)

            return JsonResponse({'message': 'SUCCESS',
                                 'access_token': access_token.decode('utf-8')}, status = 200)

        return JsonResponse({'message': 'INVALID PASSWORD OR ACCOUNT'}, status = 400)

위 코드는 로그인 엔드포인트를 구현한 것으로 사용자의 account와 패스워드를 request body에 담아받아서 해당 어카운트와 일치하는 유저가 있는지 찾은 뒤 입력받은 패스워드를 DB에 있는 패스워드의 암호화 로직을 똑같이 적용시켜 같은지 확인한 뒤, 같으면 유저의 Primary key인 id를 JWT를 활용해 토큰화 한 뒤, response에 담아서 보낸다.

Authorization

파이썬에는 '데코레이터'라는 것이 존재하는데, 쉽게 설명하면 특정 함수를 실행하기 전에 먼저 실행하는 함수를 정의 할 수 있다.
따라서 이번 실습에서는, 데코레이터를 활용한 코드를 보여주겠다.

#같은 유저 앱 디렉토리에 utils.py라는 파일을 생성하였다.
import jwt
import json

from django.http import JsonResponse
from django.core.exceptions import ObjectDoesNotExist

from my_db_settings import SECRET_KEY, ALGORITHM
from user.models import User

def login_check(func):

    def wrapper(self, request, *args, **kwargs):
        try:
            access_token = request.headers.get('Authorization', None)
            payload = jwt.decode(access_token, SECRET_KEY, algorithm=ALGORITHM)
            user_id = User.objects.get(id=payload['id'])
            request.user = user_id

        except jwt.exceptions.DecodeError:
            return JsonResponse({'message': 'INVALID TOKEN'}, status = 400)

        except User.DoesNotExist:
            return JsonResponse({'message': 'INVALID USER'}, status = 400)

        return func(self, request, *args, **kwargs)

    return wrapper

login_check이라는 데코레이터를 구현 한 것인데, 함수의 가장 최상위에 있는 func 파라미터는 로그인 하지않으면 실행하면 안되는 함수들을 받아온다(e.g. 인스타그램의 팔로우기능)
request의 헤더에 담긴 토큰을 복호화하여 유저의 아이디를 확인한 후 유저의 아이디를 다시 request에 담아 파라미터로 받아온 함수를 실행시킨다.
다른 유저를 팔로우하는 코드로 예제를 보여주겠다.

...
from user.utils import login_check

class FollowView(View):
    @login_check  # <<<<<< 데코레이터를 실행시키는 법
    def post(self, request):
        user_id = request.user.id
        followee_id = request.GET.get('followee')

        if not followee_id:
            return JsonResponse({'message': 'Check Querystring'}, status = 400)
        try:
            user     = User.objects.get(id=user_id)
            followee = User.objects.get(id=followee_id)
        except Exception:
            return JsonResponse({'message': 'INVALID_USER'}, status = 400)

        Follow(
            follower = user,
            followee = followee,
        ).save()

        return JsonResponse({'message': 'SUCCESS'}, status = 400)

팔로우를 하기전에 로그인이 되어있는지 확인하는 과정을 먼저 거친 후 확인이 되면 팔로우가 최종적으로 된다.

마무리하며

이번에 인증과 인가에 관해 공부를 하면서 기억나는 것이 하나 있는데 학부시절 과제로 실시간 채팅을 할 수 있는 웹 사이트를 구현했었는데 아무래도 실제 배포할 서비스가 아니고 로컬에서만 돌아가다 보니 사용자의 비밀번호를 암호화 하지않고 저장했었던 기억이 있다. 후에 말하겠지만 토큰 또한 따로 발행해서 클라이언트 사이드에 저장하지 않고 생으로 유저의 아이디, 이름, 프로필 사진 url 등을 로컬 스토리지에 저장했던 것이 기억난다.

이 때는 과제를 제출하는게 급급했고 딱히 암호화를 해야한다는 체점기준이 없었기에 저렇게 했었는데, 드디어 암호화와 토큰을 이용한 부족하겠지만 조금은 실제 서비스에 가깝게 구현하게 되어 기뻤다.

profile
Work hard, Play hard

0개의 댓글