본문 바로가기
소프트웨어 마에스트로/BackEnd(Django)

[Django] apple 소셜 로그인 구현기 2일차

by alpakaka 2024. 10. 18.

어제 팀원분들이 apple 소셜 로그인 관련 키를 받아주셔서 계속 진행해본다.

 

https://docs.allauth.org/en/dev/socialaccount/providers/apple.html

 

Apple - django-allauth

Toggle Light / Dark / Auto color theme Toggle table of contents sidebar

docs.allauth.org

 

여기를 참고해서 키를 넣어주면 금방 완성될...듯..? 싶다

 

아 로그인관련으로 이야기해본 결과 프론트에서 진행하고 백엔드상에서는 token -> jwt 를 사용해서 변환한 후에 돌려주면 되는 로직이였다.

그래서 딱히 위에 걸 사용할 이유가 없었다.

간단히 구현해보자.

 

생각보다 할 일이 많았다.

기존에는 android 만 가능하도록 변경하였는데, 이젠 ios 도 하다보니 기존의 google 소셜 로그인도 변경해야했다..

apple 은 device token을 원치 않으면 안 줄 수 있다는 정책이 있다는 것 같다...

그래서 가장 먼저 google 소셜 로그인을 변경했다.

 

일단 문제 상황은 다음과 같다.

ios 이면 device token 의 여부를 파악해야함. 없을 수 있다.

android 이면 device token 무조건 가져올 수 있다.

그리고 안드로이드에서도 애플로 로그인이 가능해야한다.

 

이 코드를 먼저 잘 변경해보자...!

class GoogleLogin(APIView):
    """
    request : token, device_token, type(0 : android, 1 : ios)
    """

    authentication_classes = []
    permission_classes = [AllowAny]

    def post(self, request):
        device_type = request.data.get("type", None)
        token = request.data.get("token")
        device_token = request.data.get("device_token", None)
        if not token or device_type is None:
            sentry_sdk.capture_exception(LoginException())
            raise LoginException()
        if device_type == 0 and not device_token:
            # android 의 경우 device token이 필수로 필요함
            sentry_sdk.capture_exception(LoginException())
            raise Exception("Device token is required for android login")
        try:
            if device_type == 0:  # Android
                idinfo = id_token.verify_oauth2_token(
                    token,
                    requests.Request(),
                    audience=GOOGLE_ANDROID_CLIENT_ID,
                )
            elif device_type == 1:  # iOS
                idinfo = id_token.verify_oauth2_token(
                    token, requests.Request(), audience=GOOGLE_IOS_CLIENT_ID
                )
            if "accounts.google.com" in idinfo["iss"]:
                email = idinfo["email"]
                user = User.get_or_create_user(email)
                if device_token:
                    FCMDevice.objects.get_or_create(
                        user=user, registration_id=device_token
                    )
                    refresh = CustomRefreshToken.for_user(user, device_token)
                else:
                    refresh = CustomRefreshToken.for_user_without_device(user)
                return Response(
                    {
                        "refresh": str(refresh),
                        "access": str(refresh.access_token),
                    },
                    status=status.HTTP_200_OK,
                )
        except Exception as e:
            sentry_sdk.capture_exception(e)
            return Response(
                {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
            )

아래와 같이 일차적으로 수정하고 테스트코드도 생성해서 잘 통과했다.

그런데 코드가 너무 난잡한 것 같아서 리팩토링을 진행했다. (with copilot)

 

위의 코드를 아래와 같이 리팩토링했다.

class GoogleLogin(APIView):
    """
    request : token, device_token, type(0 : android, 1 : ios)
    """

    authentication_classes = []
    permission_classes = [AllowAny]

    def post(self, request):
        try:
            device_type, token, device_token = self.validate_request(request)
            idinfo = self.verify_token(device_type, token)
            if "accounts.google.com" in idinfo["iss"]:
                email = idinfo["email"]
                user = User.get_or_create_user(email)
                refresh = self.handle_device_token(user, device_token)
                return Response(
                    {
                        "refresh": str(refresh),
                        "access": str(refresh.access_token),
                    },
                    status=status.HTTP_200_OK,
                )
        except Exception as e:
            sentry_sdk.capture_exception(e)
            return Response(
                {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
            )

    def validate_request(self, request):
        device_type = request.data.get("type", None)
        token = request.data.get("token")
        device_token = request.data.get("device_token", None)
        if not token or device_type is None:
            raise LoginException()
        if device_type == 0 and not device_token:
            raise LoginException("Device token is required for android login")
        return device_type, token, device_token

    def verify_token(self, device_type, token):
        if device_type == 0:  # Android
            return id_token.verify_oauth2_token(
                token,
                requests.Request(),
                audience=GOOGLE_ANDROID_CLIENT_ID,
            )
        elif device_type == 1:  # iOS
            return id_token.verify_oauth2_token(
                token, requests.Request(), audience=GOOGLE_IOS_CLIENT_ID
            )
        else:
            raise LoginException("Invalid device type")

    def handle_device_token(self, user, device_token):
        if device_token:
            FCMDevice.objects.get_or_create(
                user=user, registration_id=device_token
            )
            return CustomRefreshToken.for_user(user, device_token)
        else:
            return CustomRefreshToken.for_user_without_device(user)

이제 일차적으로 해결이 되었으니 다음으로 애플 소셜 로그인을 구현해보려한다.

 

위의 절차를 똑같이 거치면 되는데 문제는 여기에선 google.oauth를 사용했던데 사실 그 이유를 아직은 잘 모르겠다.

pyjwt 를 사용하지 않은 이유가 있는걸까? 애플도 마찬가지로 뭔가 있을 듯 싶었다.

https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens

 

Generate and validate tokens | Apple Developer Documentation

Validate an authorization grant code delivered to your app to obtain tokens, or validate an existing refresh token.

developer.apple.com

 

id_token.verify_oauth2_token

이걸 사용했는데 이게 아마 구글에 가서 토큰이 맞는지 물어보는 절차였던 듯 싶다.

그러므로 이 부분만 애플 로그인에 맞게 변경하면 될 듯 하다.

https://sangjuncha-dev.github.io/posts/framework/django/2021-12-28-django-oauth-apple/

 

Django 소셜로그인(oauth) apple 연동

Django oauth apple login

sangjuncha-dev.github.io

 

음 열심히 고민해보았는데, 내가 뭘 해야하는지 감이 안잡힌다. 팀원분들에게 좀 더 정확하게 범위를 정해야할 듯 싶다.

 

물어보고 얻은 내용은 다음과 같은데

CustomRefreshToken 에서 이미 jwt 를 만들어주고 있다.

따라서 정말 나는 verify 만 진행하면 된다.

빨리 끝날 것 같다. 이거 다하면 풀링 해봐야지

그럼이제 의문점이 있는데, 프론트에서 뭘 내려줄것인가...

구글과 다르게 단어가 달라서 헷갈린다. 이부분은 아직 프론트 담당 팀원분께서도 하고 있는 중이라 결정을 못했다.

일단 토큰이 내려오는 단계라고 생각하고 아래 링크를 참고해보자

https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens%EF%BB%BF

https://gist.github.com/aamishbaloch/2f0e5d94055e1c29c0585d2f79a8634e

 

Sign In with Apple using Django (Python) Backend

Sign In with Apple using Django (Python) Backend. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

여기 그대로 따라하면.. 될 것 같다.

 

계속 고민되는 사항이 저기에 client secret 이라는 단어가 있는데, 이걸 백엔드에서 만드는건지 아니면 프론트에서 만드는건지 헷갈린다.

일단 물어봤다... 일단 client_secret 를 백엔드에서 만든다고 생각하고 진행해본다.

 

client_id, key, key_id 등 등이 결국 필요해서 삭제했던 내용들을 다시 secret manager에 잘 넣어준다.

쩝 이게 맞는걸까... 싶은 느낌이 든다.

 

https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse

 

TokenResponse | Apple Developer Documentation

The response token object returned on a successful request.

developer.apple.com

이런식으로 결과가 온다고 하니 id_token 을 디코드하는게 맞는 것 같다.

근데 user에 대한 정보가 어떤게 들어 있는걸까...?

 

일단 대략적으로 구현을 완료했다. 코드는 다음과 같다.

class AppleLogin(APIView):
    """
    request : token, device_token, type(0 : android, 1 : ios)
    """

    ACCESS_TOKEN_URL = "https://appleid.apple.com/auth/token"
    APPLE_APP_ID = settings.SECRETS.get("APPLE_APP_ID")
    APPLE_SERVICE_ID = settings.SECRETS.get("APPLE_SERVICE_ID")
    APPLE_CERTICATE_FILE = settings.SECRETS.get("APPLE_CERTICATE_FILE")
    APPLE_KEY = settings.SECRETS.get("APPLE_KEY")
    APPLE_KEY_ID = settings.SECRETS.get("APPLE_KEY_ID")
    APPLE_PUBLIC_KEYS_URL = "https://appleid.apple.com/auth/keys"

    authentication_classes = []
    permission_classes = [AllowAny]

    def post(self, request):
        try:
            device_type, token, device_token = self.validate_request(request)
            idinfo = self.verify_token(device_type, token)
            if "accounts.google.com" in idinfo["iss"]:
                email = idinfo["email"]
                user = User.get_or_create_user(email)
                refresh = self.handle_device_token(user, device_token)
                return Response(
                    {
                        "refresh": str(refresh),
                        "access": str(refresh.access_token),
                    },
                    status=status.HTTP_200_OK,
                )
        except Exception as e:
            sentry_sdk.capture_exception(e)
            return Response(
                {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
            )

    def verify_token(self, access_token):
        """
        Finish the auth process once the access_token was retrieved
        Get the email from ID token received from apple
        """

        client_id, client_secret = self.get_key_and_secret()

        headers = {"content-type": "application/x-www-form-urlencoded"}
        data = {
            "client_id": client_id,
            "client_secret": client_secret,
            "refresh_token": access_token,
            "grant_type": "refresh_token",
            "redirect_uri": "https://example-app.com/redirect",
        }
        try:
            response = requests.post(
                AppleLogin.ACCESS_TOKEN_URL, data=data, headers=headers
            )
            response_dict = response.json()
            id_token = response_dict.get("id_token", None)

            if id_token:
                decoded_user_info = self.verify_apple_token(id_token)
                return decoded_user_info
        except Exception as e:
            sentry_sdk.capture_exception(e)
            raise Exception(e)

        raise LoginException("Invalid token")

    def verify_apple_token(self, token):
        # Fetch Apple's public keys
        response = requests.get(AppleLogin.APPLE_PUBLIC_KEYS_URL)
        if response.status_code != 200:
            raise LoginException("Unable to fetch Apple's public keys")
        apple_public_keys = response.json()["keys"]

        # Decode the token header to get the key ID
        headers = jwt.get_unverified_header(token)
        kid = headers["kid"]

        # Find the corresponding public key
        key = next((k for k in apple_public_keys if k["kid"] == kid), None)
        if key is None:
            raise LoginException("Invalid token")

        # Construct the public key
        public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)

        # Verify the token
        try:
            idinfo = jwt.decode(
                token,
                public_key,
                algorithms=["RS256"],
                audience=AppleLogin.APPLE_SERVICE_ID,
                issuer="https://appleid.apple.com",
            )
        except jwt.ExpiredSignatureError:
            raise LoginException("Token has expired")
        except jwt.InvalidTokenError:
            raise LoginException("Invalid token")

        return idinfo

    def get_key_and_secret(self):
        headers = {"alg": "ES256", "kid": AppleLogin.APPLE_KEY_ID}

        payload = {
            "iss": AppleLogin.APPLE_KEY,
            "iat": timezone.now(),
            "exp": timezone.now() + timedelta(days=1),
            "aud": "https://appleid.apple.com",
            "sub": AppleLogin.APPLE_SERVICE_ID,
        }

        client_secret = jwt.encode(
            payload,
            AppleLogin.APPLE_CERTICATE_FILE,
            algorithm="ES256",
            headers=headers,
        ).decode("utf-8")

        return AppleLogin.APPLE_SERVICE_ID, client_secret

    def validate_request(self, request):
        device_type = request.data.get("type", None)
        token = request.data.get("token")
        device_token = request.data.get("device_token", None)
        if not token or device_type is None:
            raise LoginException()
        if device_type == 0 and not device_token:
            raise LoginException("Device token is required for android login")
        return device_type, token, device_token

    def handle_device_token(self, user, device_token):
        if device_token:
            FCMDevice.objects.get_or_create(
                user=user, registration_id=device_token
            )
            return CustomRefreshToken.for_user(user, device_token)
        else:
            return CustomRefreshToken.for_user_without_device(user)

 너무 길고, secrets에서 들고오는 것도 거슬린다. 하지만 일단 이렇게 해서 진행을 한번 해보면 좋을 것 같다.

테스트를 해보려고 하는데 어떻게 할지 고민이다.

어쨌든 엑세스 토큰을 받아와야하는상황이다. 한번 찾아보자.

 

고민사항이다...

 

음! refresh token 을 사용하면 딱히 안해도 되는 모양이다.삭제해야지

 

https://developer.apple.com/help/app-store-connect/test-in-app-purchases/create-a-sandbox-apple-account/

 

Create a Sandbox Apple Account - App Store Connect - Help - Apple Developer

 

developer.apple.com

 

여기에서 test user 를 만드는 수밖에 없는 듯하다.

내 아이디로도 딱히 못하나...?

 

계속 헷갈린다. 프론트에서는 authorization_code 를 줄까. 아니면 refresh token  을 줄까...

 

오늘의 고민사항들을 잘 정리해서, 내일 한번 얘기해봐야할 듯 싶다.