web/django

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

민사민서 2024. 12. 26. 16:28

이제 supabase의 sso 관련 api를 활용해 로그인 기능을 어떻게 구현했는지에 대해 알아보자. (오래되어서 자세한 디테일은 기억이 안나지만...)

 

supabase에서 제공하는 api (https://supabase.com/docs/guides/auth/social-login/auth-google)를 곧바로 사용해도 되지만, 나의 경우는 서비스에서 sso 로그인과 일반 로그인을 전부 제공하고 싶어서 user 테이블을 따로 만들었다.

sso 로그인 시 원래는 auth 스키마의 users 테이블 아래 그 정보가 저장되는데, 이제 public 스키마의 users 테이블에다가도 정보를 추가로 저장하여 일반 로그인 정보와 함께 관리하고자 했다. oauth_provider 필드의 값에 따라 일반 로그인 사용자인지 구글 로그인 사용자인지 (+카카오 로그인?) 구분할 수 있다.


@api_view(['GET'])
@permission_classes([AllowAny])
def google_login(request):
    redirect_uri = f"{settings.FRONT_URL}/auth/callback" # 프론트엔드에서 지정한 콜백 URL
    
    auth_url = f"{settings.SUPABASE_URL}/auth/v1/authorize?provider=google&redirect_to={redirect_uri}"
    return redirect(auth_url)

인증 없이 접근 가능한 구글 로그인 api이다.

supabase에서 google oauth를 호출하기 위해 필요한 url을 구성하고, redirect_to 매개변수에 로그인 성공 후 사용자가 돌아올 프론트엔드 콜백 url을 지정한다

redirect() 메서드를 활용해 사용자를 구글 인증 페이지로 사용자를 이동시킨다

 

@api_view(['POST'])
@permission_classes([AllowAny])
def google_callback(request):
    access_token = request.data.get('access_token')
    
    if not access_token:
        return Response({'error': '구글 로그인 중 오류가 발생했습니다.'}, status=status.HTTP_400_BAD_REQUEST)

    # 받아온 토큰으로 사용자 정보 조회
    try:
        user_data = supabase.auth.get_user(access_token)
        user_id = user_data.user.id

        # 이미 등록된 사용자인지 검색
        existing_user = supabase.table('users').select('*').eq('user_id', user_id).eq('oauth_provider', 'google').execute()

        if not existing_user.data:
            # 새 사용자인 경우 DB에 추가
            email = user_data.user.email
            dummy_password = str(uuid.uuid4()) # 임의의 비밀번호 생성
            hashed_password = make_password(dummy_password)

            user_info = {
                'user_id': user_id,
                'email': email,
                'password': hashed_password,
                'oauth_provider': 'google',
                'full_name': user_data.user.user_metadata.get('full_name', ''),
                'created_at': timezone.now().isoformat()
            }
            result = supabase.table('users').insert(user_info).execute()
            user_id = result.data[0]['user_id']
        else:
            user_id = existing_user.data[0]['user_id']

redirect_uri에서 보내는 post 요청을 처리하는 함수이다.

Google OAuth를 통해 인증된 사용자 정보(access token)을 받아와 처리를 한다.

신규 사용자라면 public.users 테이블에 유저 데이터를 추가로 저장한다 (auth.users 테이블의 유저 데이터와 uuid로 연결되어 있음, dummy password를 생성하여 저장)

        # JWT 토큰 생성, for_user 메서드 사용하지 않고 수동으로 정보 추가
        refresh = RefreshToken()
        refresh['user_id'] = user_id
        refresh.set_exp(lifetime=settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'])
        access_token = refresh.access_token
        access_token.set_exp(lifetime=settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'])
        
        response_data = {
            'message': '로그인 되었습니다.',
            'access_token': str(access_token),
            'refresh_token': str(refresh),
        }
        return Response(response_data, status=status.HTTP_200_OK)
    except:
        return Response({'error': f'로그인 중 오류가 발생했습니다'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

그리고 refresh token 및 access token을 생성하여 리턴한다

커스텀 user 모델을 사용하고 있기에 token = RefreshToken.for_user(user) 이렇게 하는 대신 수동으로 정보를 추가한다

 

@api_view(['POST'])
@permission_classes([AllowAny])
def token_refresh(request):
    refresh_token = request.data.get('refresh_token')
    if not refresh_token:
        return Response({'error': '리프레시 토큰이 제공되지 않았습니다.'}, status=status.HTTP_400_BAD_REQUEST)

    try:
        # verify() 메서드는 RefreshToken이 instantiate될 때 자동으로 호출되더라 (default=True)
        refresh = RefreshToken(refresh_token)
        access_token = refresh.access_token
        access_token.set_exp(lifetime=settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'])

        response_data = {
            'message': '토큰이 갱신되었습니다',
            'access_token': str(access_token),
        }
        return Response(response_data, status=status.HTTP_200_OK)
    except TokenError:
        return Response({'error': '유효하지 않거나 만료된 토큰입니다.'}, status=status.HTTP_401_UNAUTHORIZED)

토큰 리프레시 관련 함수이다. refresh token이 valid한 기간 동안은 access token을 새롭게 생성하여 되돌려준다

일반 로그인/회원가입도 비슷한 방식으로 짤 수 있다.

 

@api_view(['POST'])
def logout(request):
    try:
        refresh_token = request.data.get('refresh_token')
        if not refresh_token:
            return Response({
                'error': '로그아웃 중 오류가 발생했습니다.',
                'detail': 'refresh_token이 없습니다.'
            }, status=status.HTTP_400_BAD_REQUEST)

        # 토큰을 블랙리스트에 등록함으로써 무효화
        refresh = RefreshToken(refresh_token)
        refresh.blacklist()

        return Response({
            'message': '로그아웃 되었습니다.',
        }, status=status.HTTP_200_OK)
        
    except Exception as e:
        return Response({
            'error': '로그아웃 중 오류가 발생했습니다.',
            'detail': str(e)
        }, status=status.HTTP_400_BAD_REQUEST)

로그아웃 api이다. 토큰을 블랙리스트에 등록함으로써 무효화를 시킨다.

 

@api_view(['GET'])
def user_info(request):
    try:
        user = request.user
        return Response({
            'email': user.email,
            'full_name': user.full_name,
            'oauth_provider': user.oauth_provider
        }, status=status.HTTP_200_OK)
    except:
        return Response({'error': f'사용자 정보 조회 중 오류가 발생했습니다'}, status=status.HTTP_401_UNAUTHORIZED)

사용자 정보를 가져오는 api이다.

왜 필요한가?

- access token과 refresh token의 만료 여부는 백엔드에서 확인함

- 프론트 단에서 로그인 상태 (및 기본 사용자 정보)를 확인하기 위해 인증이 필요한 엔드포인트에 요청을 보내어 확인함, 응답에 따라 프론트엔드에서 로그인 상태를 업데이트함


가물가물하지만 기억나는 트러블슈팅 몇 가지

 

1. 처음에 fly.io로 배포를 할 때 로그아웃 시 no such table: token_blacklist_blacklistedtoken 에러 계속 떴음

=> 토큰 블랙리스트 기능을 활용하기 위해서는 persistent volume이 필요함.

[[vm]]
  memory = '1gb'
  cpu_kind = 'shared'
  cpus = 2

[mounts]
  source="data_volume"
  destination="/data"
  
  ...

=> 배포된 application에서 persistent volume을 하나 파고, 이런 식으로 fly.toml 세팅을 바꿔주어 mount 시키면 해결됨

 

2. safari, 모바일 크롬 배포 환경에서 token이 쿠키에 제대로 세팅이 되지 않음

        # JWT 토큰 생성, for_user 메서드 사용하지 않고 수동으로 정보 추가
        refresh = RefreshToken()
        refresh['user_id'] = user_id
        refresh.set_exp(lifetime=settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'])
        access_token = refresh.access_token
        access_token.set_exp(lifetime=settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'])
        
        # jwt 토큰을 쿠키에 저장, 보안을 위해 파라미터 세팅, max_age 세팅함으로써 browser session 종료 시에도 유지
        response = Response({'message': '로그인 되었습니다.'}, status=status.HTTP_200_OK)
        response.set_cookie('access_token', value=str(access_token), httponly=True, samesite='Lax', secure=True, max_age=1800)
        response.set_cookie('refresh_token', value=str(refresh), httponly=True, samesite='Lax', secure=True, max_age=86400)
        return response
    except:
        return Response({'error': f'로그인 중 오류가 발생했습니다'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

이게 초반 코드 (초반에는 백엔드의 response에서 쿠키를 세팅하여 보내줬음)

 

https://stackoverflow.com/questions/58525719/safari-not-sending-cookie-even-after-setting-samesite-none-secure

 

Safari not sending cookie even after setting SameSite=None; Secure

Our application uses cookies to remember user login. Every auth API call we make, the browser attaches server-set HTTPonly cookie with the API request and gets authenticated. This behaviour seems t...

stackoverflow.com

이것 외에도 여러 사이트를 찾아보니 보안 이슈로 서드파티 쿠키를 사용하지 못한다고 함. 즉 origin이 다른 백엔드 서버에서 세팅해주는 쿠키 값을 브라우져에서 받지 않는다는 것.

 

        response_data = {
            'message': '로그인 되었습니다.',
            'access_token': str(access_token),
            'refresh_token': str(refresh),
        }
        return Response(response_data, status=status.HTTP_200_OK)

따라서 이런 식으로 json 응답에 jwt 토큰을 담아서 보내주고,

        // 서버로 전송하여 인증 처리
        const response = await api.post(
          "/auth/google/callback/",
          tokens
        );

        // 서버에서 받은 토큰을 쿠키에 저장
        Cookies.set("access_token", response.data.access_token);
        Cookies.set("refresh_token", response.data.refresh_token);

프론트에서 이런 식으로 직접 쿠키에 집어넣는 방식으로 변경하였다.

 

3. timestamp 이슈

TIME_ZONE = 'Asia/Seoul'

USE_TZ = False

supabase에 자꾸만 UTC 기준 Timestamp가 저장되는 이슈가 있어서 settings.py에서 위 두 개의 세팅을 바꿔주었고 (이제 UTC 대신 사용자 지정 timezone을 기본으로 사용함)

        for record in purchased.data:
            created_at_datetime = datetime.fromisoformat(record["created_at"])
            now_datetime = timezone.now().astimezone()
            if now_datetime - created_at_datetime >= two_weeks:
                item_id = record["item_id"]
                record["is_confirmed"] = True
                supabase.table("purchased").update({"is_confirmed" : True}).eq("item_id", item_id).execute()

코드 상에서도 timezone.now().astimezone() , datetime.fromisoformat() 등을 활용하도록 적절히 수정하였다

 

(생각나는대로 추가 예정)