반응형
미흡한 부분이 많습니다. 많은 지적 부탁드립니다. 😀
이번에 사이드 프로젝트를 진행하며 로그인하는 부분이 필요했고, 카카오 로그인만 진행하기로 했다.
전체적으로 진행했던 과정은 다음과 같다.
- [react] kakao 에 로그인 요청 - 카카오 로그인페이지로 이동됨
- 카카오 로그인 페이지에서 로그인
- [django] code 를 받음
- [django] 받은 code 를 사용하여 kakao 에 token 요청
- [django] 받은 token 을 사용하여 kakao 에 유저정보 요청
- [django] 유저 정보를 가지고
- 디비에 유저가 등록되어 있지 않으면 회원가입 후 로그인
- 디비에 유저가 등록되어 있으면 로그인
- [django] 로그인 할 때 access token 과 refresh token 을 발급하고, 쿠키에 저장하면서 react 로 redirect
- [react] 이후 django 에 요청할 때마다 쿠키의 access token 을 헤더에 설정해두고 요청
- access token 만료시
- [react] django 에서 보낸 401에러를 확인하고
- [react] axios interceptor 를 이용하여 django로 토큰 재발급 요청
- [django] refresh token 을 확인하여 access token 재발급 후 쿠키에 저장
- [react] 재발급 확인 후 이전 요청을 다시 django 에 요청
- logout
- [django] refresh token 을 blacklist 에 추가
- [django] 브라우저의 access token, refresh token 쿠키 삭제
jwt 를 간편하게 사용하기 위해 rest_framework_simplejwt 라이브러리를 사용했다.
0. 초기 세팅
# setting.py
INSTALLED_APPS = [
# my_app
'accounts',
# DRF
'rest_framework',
'rest_framework.authtoken',
# rest_auth
'rest_auth',
# registration
'django.contrib.sites',
# AUTH
'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist',
]
AUTH_USER_MODEL = 'accounts.User'
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
REST_USE_JWT = True
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = 'email'
ACCOUNT_EMAIL_VERIFICATION = 'none'
# jwt 토큰은 simplejwt의 JWTAuthentication으로 인증한다.
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.AllowAny', # 누구나 접근
),
}
SITE_ID = 1
CORS_ALLOW_CREDENTIALS = True
# 추가적인 JWT 설정
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2), #
'REFRESH_TOKEN_LIFETIME': timedelta(days=14), #
'ROTATE_REFRESH_TOKENS': True, #
'BLACKLIST_AFTER_ROTATION': False, #
'UPDATE_LAST_LOGIN': False,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
# 'VERIFYING_KEY': None,
# 'AUDIENCE': None,
# 'ISSUER': None,
# 'JWK_URL': None,
# 'LEEWAY': 0,
'AUTH_HEADER_TYPES': ('JWT',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
'JTI_CLAIM': 'jti',
# 'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
# 'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
# 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}
# accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
# 헬퍼 클래스
class UserManager(BaseUserManager):
def create_user(self, email, password, **kwargs):
"""
주어진 이메일, 비밀번호 등 개인정보로 User 인스턴스 생성
"""
if not email:
raise ValueError('Users must have an email address')
user = self.model(
email=email,
)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, email=None, password=None, **extra_fields):
"""
주어진 이메일, 비밀번호 등 개인정보로 User 인스턴스 생성
단, 최상위 사용자이므로 권한을 부여
"""
superuser = self.create_user(
email=email,
password=password,
)
superuser.is_staff = True
superuser.is_superuser = True
superuser.is_active = True
superuser.save(using=self._db)
return superuser
# AbstractBaseUser를 상속해서 유저 커스텀
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(max_length=30, unique=True, null=False, blank=False)
is_superuser = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# 헬퍼 클래스 사용
objects = UserManager()
# 사용자의 username field는 email으로 설정 (이메일로 로그인)
USERNAME_FIELD = 'email'
# accounts/serializer.py
from .models import User
from rest_framework import serializers
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__'
1. 카카오에 로그인 요청하기
const REST_API_KEY = process.env.REACT_APP_KAKAO_LOGIN_API_KEY;
const REDIRECT_URI = `${process.env.REACT_APP_BASE_URL}${process.env.REACT_APP_KAKAO_LOGIN_CALLBACK_URL}`;
export const KAKAO_AUTH_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code`;
<a style={{ color: "white" }} href={KAKAO_AUTH_URL}>카카오로 로그인</a>
- 카카오 디벨로퍼에서 앱을 추가하고
- Redirect URI 를 설정한다.
- 나의 경우 django 에서 받을 것이므로 http://localhost:8000/api/accounts/kakao/callback 으로 설정
- 나중에 django 에서 accounts 앱의 urls 에 '/kakao/callback' 과 연결된 view가 실행될 것이다.
- redirect uri 와 카카오에서 발급된 rest api key를 가지고 kakao_auth_url 을 만들어서 이동한다.
- 그렇게 하면 로그인 페이지가 나온다.
- 로그인을 한다.
2. django 에서 code -> token, token -> user info 받기
# accounts/urls.py
from django.urls import path
from .views import KakaoLoginCallback
app_name = 'accounts'
urlpatterns = [
path('kakao/callback', KakaoLoginCallback.as_view(), name='kakao_login_callback'),
]
# accounts/views.py
KAKAO_TOKEN_API = "https://kauth.kakao.com/oauth/token"
KAKAO_USER_API = "https://kapi.kakao.com/v2/user/me"
KAKAO_CALLBACK_URI = BASE_URL + "/api/accounts/kakao/callback"
# BASE_URL = 'http://localhost:8000'
class KakaoLoginCallback(generics.GenericAPIView, mixins.ListModelMixin):
def get(self, request, *args, **kwargs):
code = request.GET["code"]
if not code:
return Response(status=status.HTTP_400_BAD_REQUEST)
# kakao에 access token 발급 요청
data = {
"grant_type": "authorization_code",
"client_id": os.environ.get('SOCIAL_AUTH_KAKAO_CLIENT_ID'), # 카카오 디벨로퍼 페이지에서 받은 rest api key
"redirect_uri": KAKAO_CALLBACK_URI,
"code": code,
}
token = requests.post(KAKAO_TOKEN_API, data=data).json() # 받은 코드로 구글에 access token 요청하기
access_token = token['access_token'] # 받은 access token
if not access_token:
return Response(status=status.HTTP_400_BAD_REQUEST)
# kakao에 user info 요청
headers = {"Authorization": f"Bearer ${access_token}"}
user_infomation = requests.get(KAKAO_USER_API, headers=headers).json() # 받은 access token 으로 user 정보 요청
data = {'access_token': access_token, 'code': code}
kakao_account = user_infomation.get('kakao_account')
3. 회원가입 로그인 또는 로그인
# accounts/views.py
class KakaoLoginCallback(generics.GenericAPIView, mixins.ListModelMixin):
def get(self, request, *args, **kwargs):
# ...
# ... 대충 2번로 로직
# ...
email = kakao_account.get('email')
# 1. 유저가 이미 디비에 있는지 확인하기
try:
user = User.objects.get(email=email)
token = create_token(user=user)
res = redirect(CLIENT_URL)
set_cookie(res, token.get('access'), token.get('refresh'))
# 쿠키설정은 res.set_cookie('쿠키이름', '쿠키값')
return res
except User.DoesNotExist:
# 2. 없으면 회원가입하기
data = {
'email': email,
'password': 'test'
# 비밀번호는 없지만 validation 을 통과하기 위해서 임시로 사용
# 비밀번호를 입력해서 로그인하는 부분은 없으므로 안전함
}
serializer = UserSerializer(data=data)
if not serializer.is_valid():
return Response(data=serializer.errors, status=status.HTTP_400_BAD_REQUEST)
user = serializer.validated_data
serializer.create(validated_data=user)
# 2-1. 회원가입 하고 토큰 만들어서 쿠키에 저장하기
try:
user = User.objects.get(email=email)
token = create_token(user=user)
res = redirect(CLIENT_URL)
set_cookie(res, token.get('access'), token.get('refresh'))
return res
except:
return Response(status=status.HTTP_400_BAD_REQUEST)
4. react 에서 로그인 했는지 항상 확인하는 부분
# accounts/urls.py
from django.urls import path
from .views import KakaoLoginCallback, AuthAPIView
app_name = 'accounts'
urlpatterns = [
path('kakao/callback', KakaoLoginCallback.as_view(), name='kakao_login_callback'),
path('auth/', AuthAPIView.as_view(), name="auth"),
]
# accounts/views.py
# 로그인 확인
class AuthAPIView(APIView):
def get(self, request):
try:
access = request.auth
if not access:
return Response({'message': '토큰 없음'}, status=status.HTTP_200_OK)
payload = jwt.decode(str(access), SECRET_KEY, 'HS256')
pk = payload.get('user_id')
user = get_object_or_404(User, pk=pk)
serializer = UserSerializer(instance=user)
data = {
'id': serializer.data['id'],
'email': serializer.data['email'],
'is_staff': serializer.data['is_staff'],
}
return Response(data, status=status.HTTP_200_OK)
except(jwt.exceptions.InvalidTokenError):
print('[사용불가토큰]')
res = Response({'message': '사용불가토큰'}, status=status.HTTP_400_BAD_REQUEST)
delete_cookie(res)
return res
5. 토큰 만료 시 401에러 대처하기
const initAxiosConfig = () => {
axios.defaults.baseURL = process.env.REACT_APP_BASE_URL;
const accessToken = getCookie("access");
setAuthorizationHeaders(accessToken);
axios.interceptors.response.use(
(res) => res,
async (error) => {
const {
config,
response: { status },
} = error;
if (status === 401) {
// django 에서 401 에러를 뱉고
// 다시 django 로 토큰 리프레쉬 요청을 함
// 토큰이 리프레쉬되면 이전에 실패했던 요청을 재요청
setAuthorizationHeaders("");
await refreshAccessToken();
const accessToken = getCookie("access");
setAuthorizationHeaders(accessToken);
config.headers.Authorization = `JWT ${accessToken}`;
return axios(config);
}
return Promise.reject(error);
}
);
};
# accounts/urls.py
from django.urls import path
from .views import KakaoLoginCallback, AuthAPIView, RefreshTokenAPIView
app_name = 'accounts'
urlpatterns = [
path('kakao/callback', KakaoLoginCallback.as_view(), name='kakao_login_callback'),
path('auth/', AuthAPIView.as_view(), name="auth"),
path('refresh-token/', RefreshTokenAPIView.as_view(), name="refresh_token"),
]
# accounts/views.py
class RefreshTokenAPIView(generics.GenericAPIView, mixins.ListModelMixin):
def post(self, request):
refresh_token = request.COOKIES.get('refresh')
try:
token = jwt.decode(refresh_token, SECRET_KEY, 'HS256')
except jwt.ExpiredSignatureError:
return Response({'message': 'Token signature has expired'}, status=status.HTTP_400_BAD_REQUEST)
try:
token = update_token(refresh_token)
res = Response({'message': 'Success token refresh'})
set_cookie(res, token.get('access'), token.get('refresh'))
return res
except:
return Response({'message': 'Invalid token'}, status=status.HTTP_400_BAD_REQUEST)
6. 로그아웃
# accounts/urls.py
from django.urls import path
from .views import KakaoLoginCallback, AuthAPIView, LogoutAPIView, RefreshTokenAPIView
app_name = 'accounts'
urlpatterns = [
path('kakao/callback', KakaoLoginCallback.as_view(), name='kakao_login_callback'),
path('auth/', AuthAPIView.as_view(), name="auth"),
path('logout/', LogoutAPIView.as_view(), name="logout"),
path('refresh-token/', RefreshTokenAPIView.as_view(), name="refresh_token"),
]
# accounts/views.py
class LogoutAPIView(generics.GenericAPIView, mixins.ListModelMixin):
def post(self, request):
res = Response({'message': 'Success Logout'}, status=status.HTTP_202_ACCEPTED)
refresh_token = request.COOKIES.get('refresh')
token = RefreshToken(refresh_token)
token.blacklist()
delete_cookie(res)
return res
반응형