오늘도 개발
[Westagram] 2. 회원가입, 로그인, 팔로잉 구현으로 배운점 본문
Westagram에서는 장고에서 제공하는 기본 인증 시스템을 사용하지 않고 로그인, 회원가입, 팔로잉 기능을 구현했다.
로그인, 회원가입, 팔로잉은 유저와 관련된 기능이므로 users앱을 만들어 관리했다.
1. 모델
TimeStampModel
데이터가 추가되거나 업데이트될 때의 시각을 저장하는 추상 클래스이다.
다른 클래스는 TimeStampModel을 상속받아 사용할 수 있다.
여기서는 users 앱의 models.py에 추가했는데,
타임스탬프 모델은 다른 앱에서도 계속 필요하므로 users 앱에 넣는 것이 어색하게 느껴졌다.
나중에 알게 되었는데 프로젝트 전반에 걸쳐 사용되는 모델이나 함수는
통상적으로 core라는 이름의 앱을 만들어 거기서 관리한다고 한다.
class TimeStampModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
User
유저 정보를 저장하는 모델이다.
email과 password로 로그인할 수 있게 만들 계획이었으므로 email 중복을 방지할 장치가 필요했다.
처음에는 view에서 중복 가입을 막으면 된다는 생각에 email 필드에 unique=True를 넣지 않았다.
하지만 데이터베이스 단계에서도 중복 데이터 입력을 막아주어야 더 안전하다는 피드백을 듣고 다음과 같이 수정했다.
(실제로 views.py에 중복방지 코드를 깜빡잊고 작성하지 않았는데 데이터베이스에서 오류가 나 가입이 방지된 경우가 있었다.)
phone_number 필드는 처음에 정수로 저장해야 하나 생각했는데 문자열이 더 적절했다.
정수로 저장하면 앞자리 0을 생략하고 저장하기 때문이기도 하지만,
전화번호는 숫자로 이루어졌지만 연산에 사용할 용도가 아니기 때문이다.
follow 필드는 해당 유저가 팔로잉하는 유저의 정보를 저장하는 필드이다.
팔로잉-팔로워는 다대다 관계이다. (a유저는 b, c, d, e유저를 팔로잉할 수 있고 b, c, d, e 유저도 a 유저를 팔로잉할 수 있다.)
따라서 follow 필드를 ManyToManyField로 작성하고 through 테이블을 Follow로 설정했다.
이 때 첫 번째 인자로 'self'를 넣지 않으면 오류가 난다.
follow 필드는 유저 클래스, 즉 자기 자신을 참조하기 때문이다.
class User(TimeStampModel):
name = models.CharField(max_length=45)
email = models.CharField(max_length=254, unique=True)
password = models.CharField(max_length=200)
phone_number = models.CharField(max_length=200)
follow = models.ManyToManyField('self', through='Follow')
class Meta:
db_table = 'users'
Follow
User의 through 테이블이다.
user, following에 related_name을 지정하지 않으면 오류가 난다.
이는 user도 following도 똑같이 User를 참조하기 때문이다.
한 User과 연관된 Follow를 가져오라는 역참조 명령을 내리는 경우
user를 가져오라는 건지 following을 가져오라는 건지 django는 알 수 없다.
(참고 : ForeignKey, ManyToManyField - 정참조, 역참조, related_name)
class Follow(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name='follows')
following = models.ForeignKey('User', on_delete=models.CASCADE, related_name='followed_by')
class Meta:
db_table = 'follows'
<models.py> 정리
# users/models.py
from django.db import models
class TimeStampModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class User(TimeStampModel):
name = models.CharField(max_length=45)
email = models.CharField(max_length=254, unique=True)
password = models.CharField(max_length=200)
phone_number = models.CharField(max_length=200)
follow = models.ManyToManyField('self', through='Follow')
class Meta:
db_table = 'users'
class Follow(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name='follows')
following = models.ForeignKey('User', on_delete=models.CASCADE, related_name='followed_by')
class Meta:
db_table = 'follows'
2. 뷰
SignUpView
이름, 이메일, 비밀번호, 전화번호로 회원가입 할 수 있다.
이메일 / 비밀번호를 입력하지 않았을 때, 중복된 이메일인 경우, 이메일/비밀번호 형식이 틀린 경우 400 에러를 반환한다.
회원가입 성공시 201 SUCCESS 메시지를 반환한다.
요청 형식은 다음과 같다.
http -v POST 127.0.0.1:8000/users/sign-up name='바둑' email='ba@naver.com' password='ba123!!!!!' phone_number='01012341234'
regex는 SignUpView 외 다른 곳에도 사용할 수 있기 때문에 상수로 만들었다.
처음에는 re.compile로 regex를 컴파일해서 사용했는데,
공식 문서에서 자주 사용하는 regex가 아닌 경우엔 re.comile()대신 re.match()를 권장하므로 다음과 같이 수정했다.
처음에는 User.objects.get(email=email)로 이메일 존재 여부를 확인했는데
User.objects.filter(email=email).exists()가 더 가독성이 좋을 것 같다는 리뷰를 받고 수정했다.
REGEX_EMAIL = '^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'
REGEX_PASSWORD = '''^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@!%*?&!"£$%^&*()_+{}:@~<>?|=[\];'#,.\/\\-])[\S]{8,}$'''
class SignUpView(View):
def post(self, request):
try:
request_data = json.loads(request.body)
name = request_data['name']
email = request_data['email']
password = request_data['password']
phone_number = request_data['phone_number']
if User.objects.filter(email=email).exists():
return JsonResponse({'message' : 'same email exists'}, status = 400)
# 이메일 validation
if not re.match(REGEX_EMAIL, email):
return JsonResponse({'message' : 'email validation failed'}, status = 400)
# 비밀번호 validation(최소 1개 문자, 숫자, 특수문자, 최소 8자)
if not re.match(REGEX_PASSWORD, password):
return JsonResponse({'message' : 'password validation failed'}, status = 400)
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode()
User.objects.create(
name = name,
email = email,
password = hashed_password,
phone_number = phone_number
)
return JsonResponse({'message' : 'SUCCESS'}, status = 201)
except KeyError:
return JsonResponse({'message' : 'KEY_ERROR'}, status = 400)
LoginView
이메일, 비밀번호로 로그인할 수 있다.
인증 성공한 경우 jwt 발급하고 200 'SUCCESS' 반환,
존재하지 않는 이메일이거나 비밀번호가 틀린 경우 401 에러를 반환한다.
처음에는 try-except문을 여러 번 반복해서 사용했는데,
가독성을 위해 try는 한 번만 작성하고 except는 마지막에 몰아서 작성했다.
무거운 데이터를 다루는 복잡한 함수인 경우 처음 방법이 좋을 것 같지만
이 함수는 간단하기 때문에 가독성을 우선시했다.
JWT 발행 시 필요한 secret과 algorithm은 노출되면 안되기 때문에 환경변수로 처리해야 한다.
처음에는 my_settings.py에 secret과 algorithm을 넣고 다음과 같이 임포트해서 사용했다.
from my_settings import ALGORITHM, SECRET
하지만 이 경우 ALGORITM과 SECRET 변수명을 바꿔야 하는 경우
my_settings를 임포트한 모든 뷰에서 변수명을 수정해주어야 한다.
views.py가 my_settings.py에 의존성이 생긴다는 뜻이다.
그래서 settings.py에 my_settings를 임포트하고
각 views.py는 settings를 임포트하는 방식으로 코드를 수정했다.
from django.conf import settings
class LoginView(View):
def post(self, request):
try:
request_data = json.loads(request.body)
email = request_data['email']
password = request_data['password']
db_user = User.objects.get(email=email)
if not bcrypt.checkpw(password.encode('utf-8'), db_user.password.encode('utf-8')):
return JsonResponse({'message' : 'INVALID_USER'}, status = 401)
access_token = jwt.encode({'id' : db_user.id}, settings.SECRET_KEY, algorithm = settings.JWT_ALGORITHM)
return JsonResponse({'message': 'SUCCESS', 'access_token': access_token}, status = 200)
except KeyError:
return JsonResponse({'message' : 'KEY_ERROR'}, status = 400)
except User.DoesNotExist:
return JsonResponse({'message' : 'INVALID_USER'}, status = 401)
FollowView
요청 형식은 다음과 같다.
아래 요청은 1번 유저가 4번 유저를 팔로잉하겠다는 내용이다.
http -v POST 127.0.0.1:8000/users/follow/4 Autorization:"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MX0.rtlo43o89Y1m1A5qLLk73-4odELEd4tb7lPWncHrKGM"
팔로우는 로그인 한 유저만 사용할 수 있는 기능이다.
처음엔 FollowView에서 자체적으로 로그인 인증을 했는데
다른 기능을 작업하다보니 게시물 작성, 좋아요 기능 등 로그인이 필요한 곳이 많았다.
같은 코드를 자꾸 반복해서 작성하다 보니 비효율적이라는 생각이 들어 데코레이터를 만들었다.
우선 users 앱 위치에 utils 폴더를 만들고 login_decorater 파일을 만든 후 login_required라는 데코레이터를 작성했다.
아래 FollowView는 login_required 함수를 임포트해서 사용하고 있다.
from utils.login_decorator import login_required
class FollowView(View):
@login_required
def post(self, request, **kwargs):
try:
# 유저 아이디는 request 오브젝트에서,
# 유저가 팔로잉 할 사람의 아이디는 kwargs에서 꺼내옴
user_id = request.user.id
following_id = kwargs['following_id']
if user_id == following_id:
return JsonResponse({'message' : 'cannot follow oneself'}, status = 401)
user = request.user
user_to_follow = User.objects.get(id=following_id)
if user.follow.filter(id=following_id).exists():
return JsonResponse({'message' : 'already following'}, status = 401)
Follow.objects.create(
user = user,
following = user_to_follow
)
return JsonResponse({'message' : 'SUCCESS'}, status = 201)
except KeyError:
return JsonResponse({'message' : 'KEY_ERROR'}, status = 400)
except User.DoesNotExist:
return JsonResponse({'message' : 'INVALID_USER'}, status = 401)
login_decorator
wrapper의 파라미터는 내부함수에 작성한 파라미터와 일치해야 한다.
(이름은 다를 수 있지만 종류와 위치는 같아야 함)
내부함수에 들어가는 인수가 wrapper에 먼저 들어가기 때문이다.
내부함수에 self, request, **kwargs가 들어가므로
wrapper에도 self, request, **kwargs라는 파라미터를 지정해주었다.
아래 wrapper는 요청 메시지 헤더에서 jwt 토큰을 받아와 유저를 인증한다.
토큰이 잘못되었거나 유효기간이 잘못되었으면 내부 함수를 실행하지 않고 바로 401 에러 메시지를 반환한다.
인증에 문제가 없는 경우 내부 함수를 호출하고 호출로 얻은 값을 반환한다.
유저 아이디로 유저 오브젝트를 불러와 request.user에 저장해주면
내부함수에서 request.user로 유저 오브젝트를 사용할 수 있다.
import jwt
from django.http import JsonResponse
from django.views import View
from django.conf import settings
from users.models import User
def login_required(func):
def wrapper(self, request, **kwargs):
try:
access_token = request.headers.get('Autorization', None).encode('utf-8')
payload = jwt.decode(access_token, settings.SECRET_KEY, algorithms = settings.JWT_ALGORITHM)['id']
request.user = User.objects.get(id = payload['user_id'])
return func(self, request, **kwargs)
except KeyError:
return JsonResponse({'message' : 'KEY_ERROR'}, status = 400)
except jwt.exceptions.InvalidSignatureError:
return JsonResponse({'message' : 'INVALID_TOKEN'}, status = 401)
except jwt.ExpiredSignatureError :
return JsonResponse({'message':"Expired Token"}, status=401)
return wrapper
3. urls.py
원래 회원가입을 /sign_up으로 작성했는데 url에서는 언더바 사용을 지양한다는 것을 배웠다.
언더바 보다 하이픈이 좀 더 RESTful하므로 /sign-up으로 변경했다.
from django.urls import path
from users.views import SignUpView, LoginView, FollowView
urlpatterns = [
path('/sign-up', SignUpView.as_view()),
path('/login', LoginView.as_view()),
path('/follow/<int:following_id>', FollowView.as_view())
]
'TIL & 프로젝트 회고' 카테고리의 다른 글
| [1차 프로젝트 록차] 상품 목록 GET api 제작기 2 - 어떻게 필터링을 구현할까 (0) | 2022.07.21 |
|---|---|
| [1차 프로젝트 록차] 상품 목록 GET api 제작기 1 - RESTful한 엔드포인트 만들기 (0) | 2022.07.21 |
| [Westagram] 1.Github 사용으로 느낀점 (0) | 2022.07.17 |
| [Westagram] 잘못된 로그인 시도 시 발생하는 에러 (0) | 2022.07.08 |
| [위코드] 자기소개 페이지 - 랜덤 TMI 기능 설명 (0) | 2022.06.05 |