소프트웨어 마에스트로/BackEnd(Django)

[Django] apple social login 3회차

alpakaka 2024. 10. 21. 17:40

오늘은 apple social login 을 다 붙여볼 예정이다.

일단 해볼 것들을 쉽게 나열해보면

1. 뷰 만들기

2. 테스트하기

정도가 있다 사실상 구글로그인과 다른점이 없다.

그런데 새롭게 공지받은 내용이 있는데 안드로이드도 디바이스 토큰을 주지 않을 경우가 있다고 한다.

그래서 이부분을 수정해본다.

 

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",
        }
        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)

지금보니 좀 심각한 코드의 상태이다. 여기에서 handle_device_token 등 구글과 애플 모두 사용하는 것의 경우 모델쪽으로 빼는 게 나을 듯 보인다.

 

일단 안드로이드는 무조건 디바이스 토큰을 받아야한다는 로직을 제외한다.

그리고 구글 로그인과 마찬가지로 토큰을 인증받는 로직으로 변경한다. 

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

 

Fetch Apple’s public key for verifying token signature | Apple Developer Documentation

Fetch Apple’s public key to verify the ID token signature.

developer.apple.com

로직은 다음과 같다.

1. auth/keys 에서 공개키를 받아온다.

2. 프론트로부터 받은 토큰을 해당 공개키로 디코드한다.

3. 디코드한 내용을 바탕으로 fcm이나 여러가지를 만든다.

이렇게 진행될 것 같다.

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

    APPLE_SERVICE_ID = settings.SECRETS.get("APPLE_SERVICE_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)
            # verify apple token and get email
            email = self.verify_token(device_type, token)
            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, token):
        """
        Finish the auth process once the access_token was retrieved
        Get the email from ID token received from apple
        """
        # Apple 공개 키 가져오기
        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"]
        # JWT 디코드 및 서명 검증
        header = jwt.get_unverified_header(token)

        # 헤더의 'kid'와 일치하는 Apple 공개 키 선택
        public_key = next(
            key
            for key in apple_public_keys["keys"]
            if key["kid"] == header["kid"]
        )

        if public_key is None:
            raise LoginException("Invalid token")

        # Verify the token
        try:
            decoded_token = jwt.decode(
                id_token,
                public_key,
                algorithms=["RS256"],  # Apple의 서명 알고리즘
                audience=AppleLogin.APPLE_SERVICE_ID,
                issuer="https://appleid.apple.com",
            )
            return decoded_token.get("email")
        except jwt.ExpiredSignatureError:
            sentry_sdk.capture_exception(jwt.ExpiredSignatureError)
            raise LoginException("Token has expired")
        except jwt.InvalidTokenError:
            sentry_sdk.capture_exception(jwt.ExpiredSignatureError)
            raise LoginException("Invalid token")
        except Exception as e:
            sentry_sdk.capture_exception(e)
            raise LoginException("An unexpected error occurred")

    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()
        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)

간단해졌다!

구글이랑 로직이 거의 겹쳐서 분기문으로 해도 괜찮을 것 같기도한데.. 일단 나눠놓고 테스트 후에 괜찮으면 리팩토링을 진행하면 좋을 듯 보인다.

 

센트리 수정하기

센트리에서 prod, dev 모두 구분없이 issue 가 쌓여서 수정해주었다.

def set_sentry_init_setting(SENTRY_ENVIRONMENT):
    sentry_sdk.init(
        dsn=sentry_dsn,
        traces_sample_rate=0.1,
        release=PROJECT_VERSION,
        profiles_sample_rate=0.1,
        environment=SENTRY_ENVIRONMENT,
        integrations=[
            DjangoIntegration(
                transaction_style="url",
                middleware_spans=True,
                signals_spans=True,
                signals_denylist=[
                    django.db.models.signals.pre_init,
                    django.db.models.signals.post_init,
                ],
                cache_spans=False,
            ),
        ],
    )

이런식으로 함수로 초기화해줄 수 있도록 해주었다.

from onestep_be.settings import MIDDLEWARE, set_sentry_init_setting


set_sentry_init_setting("Testing")

그래서 개발환경마다 구분할 수 있도록 해주었다.

 

일단 애플의 급한 불을 껐다!!