[Django] apple social login 3회차
오늘은 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 등 구글과 애플 모두 사용하는 것의 경우 모델쪽으로 빼는 게 나을 듯 보인다.
일단 안드로이드는 무조건 디바이스 토큰을 받아야한다는 로직을 제외한다.
그리고 구글 로그인과 마찬가지로 토큰을 인증받는 로직으로 변경한다.
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")
그래서 개발환경마다 구분할 수 있도록 해주었다.
일단 애플의 급한 불을 껐다!!