[Django] Account 로그인/회원 가입 부분 암호화, 인증/인가 기능 추가 - 2

ybear90·2020년 2월 16일
0

Django

목록 보기
11/12
post-custom-banner

이전 포스트에서 암호화, 인증 인가 관련 내용을 토대로 실제 django 기반 api에 적용을 해 보았다.

회원 가입 시 password 암호화

회원 가입 기능 부분에서 password 데이터를 bcrypt를 통해 암호화를 하였다. 암호화 알고리즘은 항상 byte 데이터를 기반으로 작동하기 때문에 encoding 과정이 필수다.

# account/views.py
class SignUpView(View):
    def post(self, request):
        account_data = json.loads(request.body)

        try:
            if not Account.objects.filter(user_account=account_data['user_account']).exists():
                Account(
                    user_account=account_data['user_account'],
                    password=bcrypt.hashpw(account_data['password'].encode('utf-8'), bcrypt.gensalt()).decode() 
                ).save()
            else:
                return HttpResponse(status=409)
        except KeyError:
            return HttpResponse(status=400)

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

하지만 주의 했어야 했던 부분은 암호화를 한 password를 저장할 때 였다. 실제 로그인 기능(SignIn)에서 password 검증(비교) 작업을 하게 된다. 그 때 해당 데이터를 평문이 아닌 byte 데이터로 비교하는 로직으로 checkpw() 에 넣어주어야 한다. 만일 encode 한 채로 DB에 저장했다면 checkpw() 부분에서 에러가 날 수 있으므로(ValueError : Invalid Salt) encode, decode여부를 제대로 확인하고 작업을 해야 할 것이다.

# account/views.py
class SignInView(View):
    def post(self, request):
        account_data = json.loads(request.body)

        try:
            if Account.objects.filter(user_account=account_data['user_account']).exists():
                account = Account.objects.get(user_account=account_data['user_account'])

                if bcrypt.checkpw(account_data['password'].encode('utf-8'), account.password.encode('utf-8')):
                    token = jwt.encode({ 'user_id' : account.id }, SECRET_KEY, algorithm='HS256')
                    return JsonResponse({ 'access_token' : token.decode('utf-8') }, status=200)
                return HttpResponse(status=401)
            
            return HttpResponse(status=400)
        
        except KeyError:

실제 구현했던 SignInView 부분이다. 비밀번호가 맞는지 encoding을 거쳐 확인 한 다음 유효한 비밀번호 임이 확인이 된다면 jwt token을 생성하여 JsonResponse로 access token을 FE(클라이언트)에게 전달하는 부분이다. 실제 서비스에선 만들어진 tokenrequest.header['Authorization'] 영역에 저장되게 된다. 그래서 인증이 필요한 서비스 마다 생성한 로그인 인증 decorator등을 사용하여 request 헤더로 부터 유효한 access_token이 있는지 지속적으로 검증하고 인가가 된 사용자만 유효한 서비스를 이용하도록 한다.

테스트 결과

$ http -v http://127.0.0.1:8000/account/sign-up user_account=test3 password=12345
POST /account/sign-up HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 46
Content-Type: application/json
Host: 127.0.0.1:8000
User-Agent: HTTPie/2.0.0

{
    "password": "12345",
    "user_account": "test3"
}

HTTP/1.1 200 OK
Content-Length: 22
Content-Type: application/json
Date: Sat, 15 Feb 2020 13:29:19 GMT
Server: WSGIServer/0.2 CPython/3.8.1
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
    "message": "SUCCESS"
}
$ http -v http://127.0.0.1:8000/account/sign-in user_account=test3 password=12345
POST /account/sign-in HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 46
Content-Type: application/json
Host: 127.0.0.1:8000
User-Agent: HTTPie/2.0.0

{
    "password": "12345",
    "user_account": "test3"
}

HTTP/1.1 200 OK
Content-Length: 120
Content-Type: application/json
Date: Sat, 15 Feb 2020 13:29:29 GMT
Server: WSGIServer/0.2 CPython/3.8.1
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMn0.1r2W0cauMPSYMVVeu-I7w3CfamF3NDEcnWiBPmHFvCY"
}

SECRET_KEY 별도 은닉

jwt를 사용하기 위해선 SECRET_KEY가 있어야 한다. 이 프로젝트 내에서만 이용해야 하는 것이므로 관련 개발자가 아니면 공개되어서는 안되는 정보기에 실제 Git 등에 올릴 때도 따로 파일을 두고 저장한 후 제외시켜두고 올린다. 이 외에도 은닉해야 하는 정보는 별도로 하나의 파일에 변수등에 정의를 해 두어서 관리를 해주는 것이 좋다.

아래와 같이 settings.py에서 SECRET_KEY 부분을 수정했다

import os
from . import my_settings

# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = my_settings.secret_dict['SECRET_KEY']

같은 경로에 my_settings.py를 다음과 같이 만들었다.

secret_dict = {
    'SECRET_KEY' : 'my secret key'
}

실제로 git remote repository와 연동할 때 해당 파일은 .gitignore등에 추가해서 예외처리 해주는 것은 필수!

decorator 기능을 활용한 회원 인증 여부 확인 함수 구현

python의 decorator는 어떤 특정 함수를 실행하기 앞서 무조건 먼저 실행되게끔 하는 중첩 함수로 특정 기능을 각각의 함수나 로직에 반복 구현하지 않게 하고 계속 그 기능을 활용하기 위해 만들어 쓴다. 실제 비즈니스 로직을 구현하다 보면 user가 로그인이 되어 있는지 지속적으로 확인하고 인가를 해주어야 하는 상황이 많이 발생하는데 그럴때 마다 decorator를 활용하여 인가 확인을 진행해 준다. django에 해당하는 decorator가 내장되어 있지만. 공부하는 김에 아래와 같이 구현할 수 있다.

login 인증 데코레이터 구현 로직

  1. request header에 token이 있다면 ?

    1. 해당 access token이 유효한 토큰이면 ?

      그 토큰을 decode하여 Account를 불러온다

    2. 해당 access token이 유효하지 않다면 ?

      토큰 에러 메세지를 리턴한다

    3. 유효한 토큰 이지만 없는 계정이라면 ?

      계정 에러에 대한 메세지를 리턴하고 마친다

  2. request header에 token이 없다면 ?

    에러 메세지를 보여주고 인증을 마친다

위 로직을 참고하여 아래와 같이 구현했다.

import jwt
import json
from django.http                import JsonResponse
from .models                    import Account
from westargram_api.settings    import SECRET_KEY


def login_required(func):
    def wrapper(self, request, *args, **kwargs):
        access_token = request.headers.get("Autorization", None)

        if access_token is not None:
            try:
                decode_token = jwt.decode(access_token, SECRET_KEY, algorithm='HS256')
                account_id = decode_token['account_id']
                account = Account.objects.get(id=account_id)
                request.user = account

                return func(self, request, *args, **kwargs)
            except jwt.DecodeError:
                return JsonResponse({'message' : 'INVALID TOKEN'}, status=400)
            except Account.DoesNotExist:
                return JsonResponse({'message' : 'ACCOUNT NOT EXIST'}, status=400)
        else:
            return JsonResponse({'message' : "LOGIN REQUIRED"}, status=401)


    return wrapper

만든 login_required를 테스트 부분을 만들 실제 비즈니스 로직에 적용해 보았다.

# account/views.py
class CheckAccessView(View):
    @login_required
    def get(self, request):
        return JsonResponse({'user_info' : {
            'user_account' : request.user.user_account,
        }})

간단히 인가가 이뤄지면 유저 아이디를 보여주는 View이다. 실제 테스트를 해보면 다음과 같이 나온다.

$ http -v http://127.0.0.1:8000/account/check-user "Authorization: {my_access_token}"
GET /account/check-user HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Authorization: {my_access_token}
Connection: keep-alive
Host: 127.0.0.1:8000
User-Agent: HTTPie/2.0.0



HTTP/1.1 200 OK
Content-Length: 40
Content-Type: application/json
Date: Sun, 16 Feb 2020 13:59:04 GMT
Server: WSGIServer/0.2 CPython/3.8.1
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
    "user_info": {
        "user_account": "test3"
    }
}

실제 댓글(comment)기능에 인가 기능 추가

만든 login_required 를 다른 앱(Comment)에 테스트를 해 보았다. 실제로 인스타그램을 생각해 보았을 때 로그인을 하지 않더라도 일부 댓글등의 내용을 확인할 수 있다. 다만 가입한 회원에 한해서 댓글을 달 수 있도록 되어 있으므로 CommentView에 있는 post()에 해당 인가 기능을 추가해 보았다.

# comment/views.py

import json
from django.views   import View
from django.http    import HttpResponse, JsonResponse
from account.auth   import login_required
from .models        import Comment

class CommentView(View):
    @login_required
    def post(self, request):
        comments_data = json.loads(request.body)
        Comment(
            user_account=request.user.user_account,
            comments=comments_data['comments']
        ).save()

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

    def get(self, request):
        return JsonResponse({'comments':list(Comment.objects.values())}, status=200)

위와 같이 decorator를 추가해 주고 user_account 부분에 request.user.user_account로 수정을 하여 실제 인가된 사용자를 받도록 하였다. 이로서 댓글 입력은 인가된 user만 달 수 있게 되었다.

실제로 테스트를 해보면 다음과 같다.

# 로그인 한 토큰이 없을 경우
$ http -v http://127.0.0.1:8000/comment user_account=whois comments='dsafdafadf'
POST /comment HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 51
Content-Type: application/json
Host: 127.0.0.1:8000
User-Agent: HTTPie/2.0.0

{
    "comments": "dsafdafadf",
    "user_account": "whois"
}

HTTP/1.1 401 Unauthorized
Content-Length: 29
Content-Type: application/json
Date: Sun, 16 Feb 2020 14:48:52 GMT
Server: WSGIServer/0.2 CPython/3.8.1
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
    "message": "LOGIN REQUIRED"
}

access token이 첨부된 채로 댓글을 달 경우

http -v http://127.0.0.1:8000/comment "Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhY2NvdW50X2lkIjoxMX0.qRcpqR5QP1l7ONTWUw-QR7NxbIyikvk1_MdD1-7IMSU" comments="hi"
POST /comment HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhY2NvdW50X2lkIjoxMX0.qRcpqR5QP1l7ONTWUw-QR7NxbIyikvk1_MdD1-7IMSU
Connection: keep-alive
Content-Length: 18
Content-Type: application/json
Host: 127.0.0.1:8000
User-Agent: HTTPie/2.0.0

{
    "comments": "hi"
}

HTTP/1.1 200 OK
Content-Length: 22
Content-Type: application/json
Date: Sun, 16 Feb 2020 14:54:36 GMT
Server: WSGIServer/0.2 CPython/3.8.1
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
    "message": "SUCCESS"
}
$ http -v http://127.0.0.1:8000/comment
GET /comment HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: 127.0.0.1:8000
User-Agent: HTTPie/2.0.0

HTTP/1.1 200 OK
Content-Length: 450
Content-Type: application/json
Date: Sun, 16 Feb 2020 14:54:52 GMT
Server: WSGIServer/0.2 CPython/3.8.1
X-Content-Type-Options: nosniff
X-Frame-Options: DENY

{
    "comments": [
        {
            "comments": "hajdhklfjasdklfjldsk",
            "created_at": "2020-02-08T12:15:46.264Z",
            "id": 1,
            "updated_at": "2020-02-08T12:15:46.264Z",
            "user_account": "def"
        },
        {
            "comments": "gotohell",
            "created_at": "2020-02-08T12:16:25.028Z",
            "id": 2,
            "updated_at": "2020-02-08T12:16:25.028Z",
            "user_account": "dgdgd"
        },
        {
            "comments": "hi",
            "created_at": "2020-02-16T14:54:36.194Z",
            "id": 3,
            "updated_at": "2020-02-16T14:54:36.194Z",
            "user_account": "test2"
        }
    ]

댓글 달려 있는 것을 확인할 수 있다.

profile
wanna be good developer
post-custom-banner

0개의 댓글