web/django

[django] custom authentication 및 custom user를 통해 구글 로그인 구현하기 (feat. supabase) - 1

민사민서 2024. 12. 26. 15:05

코드 작성한지 몇 달 지나서 (사소한 트러블슈팅들은) 기억은 안나지만 세팅 방법 등을 정리해두고자 글을 남긴다~~

 

 

장고에서는 기본적으로 (여러 field와 method가 정의된) Users 모델을 제공하고 있다. username, password, email, first_name, last_name, is_staff, is_active, is_superuser 등의 필드와 check_password, set_password, authenticate 등의 메서드가 포함되어 있다. 기본 User 모델을 상속받아 customize하면 된다. 기본적으로 User 모델은 장고의 세션 인증, 토큰 인증JWT 인증과 연동된다 !!


인가(Permission)과 관련되어서는 AllowAny, IsAuthenticated, IsAdminUser 등의 클래스로 권한을 제어한다. 모든 사용자에게 접근을 허용할지, 인증된 사용자 외에는 401 Unauthorized 응답을 돌려줄지 등등이 있다.

'DEFAULT_PERMISSION_CLASSES' : (
    'rest_framework.permissions.AllowAny',
),

인증(Permission)과 관련되어서는 대표적으로 jwt 방식의 인증이 있겠다. rest_framework_simplejwt는 기본적으로 Django의 User 모델을 사용하여 사용자를 인증하며, User 모델의 정보를 토대로 Access Token과 Refresh Token을 생성한다. (cf. 세션 기반 인증을 사용하려면 rest_framework.authentication.SessionAuthentication)

'DEFAULT_AUTHENTICATION_CLASSES': (
    'rest_framework_simplejwt.authentication.JWTAuthentication',
)

아래와 같이 DRF에서 요청이 들어오면 DEFAULT_AUTHENTICATION_CLASSES에 따라 사용자를 인증하고, 인증에 성공하면 request.user에 User 모델 객체가 설정된다 (인증되지 않은 유저는 AnonymousUser 객체). 이러한 인증 시스템은 User 모델(혹은 확장된 User 모델) 기준으로 식별하므로 User 모델이 없으면 동작하지 않는다.

if not request.user.is_authenticated:
    return Response(
        {"detail": "please signin"}, status=status.HTTP_401_UNAUTHORIZED
    )
author = request.user

GraduArt의 백엔드 서버를 만들 당시에 로그인 방식은 Supabase를 통한 Google 로그인(SSO 방식)이었다. 즉 인증 과정에서 사용자 정보를 Supabase로부터 받아오면 되기에 장고의 기본 User 모델이 굳이 필요하지 않았다 = 기본 jwt 인증 시스템을 활용할 수 없었다

(작가 및 작품 정보도 supabase에 저장해두고 있었기 때문에 굳이 장고 ORM을 활용해 User 모델을 정의하고 관리하는 건 불필요하다고 생각했다 => User 정보도 supabase에 저장하는 것으로 통일)

 

따라서 해야 했던 것은

- custom user 모델을 정의하고 (외부 인증 제공자에서 받은 정보 중 무엇을 꺼내와 보여줄지 customize하기 위해)

- custom authentication 클래스를 생성하고 (JWT 인증과 커스텀 User 모델을 연동하려면 DRF의 BaseAuthentication을 상속받아 사용자 인증 로직을 작성해야 함)

- jwt token도 custom user 정보를 활용해 새롭게 생성하고

- api view에서 user 정보를 가져다 쓸 때에도 바뀐 정의대로 가져다 쓰는 식으로 진행해야 했음


REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES' : [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'authorize.custom_authentication.CustomJWTAuthentication', # Custom JWT Authentication
    ]
}

이런 식으로 authentication class에다가 custom class를 넣었다. (settings.py)

blacklist 기반 필터링보다는 whitelist 기반 필터링이 보안적인 측면에서 더 좋을 것 같아서 AllowAny가 아니라 IsAuthenticated로 넣었고, 인증이 필요없는 api(회원가입, 로그인, 작품 검색, 작품 조회 등등)에만 @permission_classes([AllowAny]) 이런 데코레이터를 넣었다.

 

from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework import exceptions
from .custom_user import CustomUser
from supabase import create_client
from django.conf import settings
# Initialize Supabase client
supabase = create_client(settings.SUPABASE_URL, settings.SUPABASE_KEY)

class CustomJWTAuthentication(JWTAuthentication):
    def authenticate(self, request):
        header = self.get_header(request)
        if header is None:
            raw_token = request.COOKIES.get('access_token')
            if raw_token is None:
                return None
        else:
            raw_token = self.get_raw_token(header)
            if raw_token is None:
                return None
        
        try:
            validated_token = self.get_validated_token(raw_token)
            user = self.get_user(validated_token)
            return (user, validated_token)
        except exceptions.AuthenticationFailed:
            # Return None to indicate authentication failure without raising an exception
            return None

    def get_user(self, validated_token):
        try:
            user_id = validated_token['user_id']
        except KeyError:
            raise exceptions.AuthenticationFailed(
                _('Token contained no recognizable user identification'),
                code='token_invalid'
            )

        # Fetch the user from Supabase
        user_data = supabase.table('users').select('*').eq('user_id', user_id).execute()
        if not user_data.data:
            raise exceptions.AuthenticationFailed(_('User not found'), code='user_not_found')

        user_info = user_data.data[0]
        return CustomUser(user_info)

rest_framework_simplejwt.authentication의 JWTAuthentication을 상속받아 CustomJWTAuthentication 클래스를 작성하였으며, get_user 메서드를 오버라이드하여 사용자 정보를 Supabase에서 가져오도록 수정하였다 !!

 

코드 흐름을 보자면 다음과 같다
    # @permission_classes([AllowAny]) 데코레이터가 적용되지 않은 API에 접근하면 default IsAuthenticated 클래스가 적용되어
    # CustomJWTAuthentication.authenticate(request) 메서드가 호출된다.
    # authenticate 메서드는 쿠키에서 access_token을 가져와서 유효성 검사를 한다.
    # 토큰이 유효하다면 get_user 메서드를 호출하여 사용자 정보를 가져온다.
    # 최종적으로 CustomUser 인스턴스와 토큰을 반환한다.

 

# Django's authentication system을 사용하기 위한 custom user class
class CustomUser:
    def __init__(self, user_info):
        self.user_id = user_info['user_id']
        self.email = user_info['email']
        self.full_name = user_info.get('full_name', '')
        self.oauth_provider = user_info.get('oauth_provider', '')
        self.is_active = True
        self.is_authenticated = True

    def __str__(self):
        return self.email

    @property
    def is_anonymous(self):
        return False

    @property
    def is_staff(self):
        return False

반환되는 CustomUser 인스턴스는 이런 식으로 구현해뒀다.

 

@api_view(['POST'])
def insert_cart(request):
    try:
        # 사용자 정보와 상품 정보 가져오기
        user_id = request.user.user_id

api 에서는 instance의 멤버 변수에 이렇게 접근하면 된다