어제 팀원분들이 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 을 사용하면 딱히 안해도 되는 모양이다.삭제해야지
Create a Sandbox Apple Account - App Store Connect - Help - Apple Developer
developer.apple.com
여기에서 test user 를 만드는 수밖에 없는 듯하다.
내 아이디로도 딱히 못하나...?
계속 헷갈린다. 프론트에서는 authorization_code 를 줄까. 아니면 refresh token 을 줄까...
오늘의 고민사항들을 잘 정리해서, 내일 한번 얘기해봐야할 듯 싶다.
'소프트웨어 마에스트로 > BackEnd(Django)' 카테고리의 다른 글
[Django] apple social login 3회차 (1) | 2024.10.21 |
---|---|
[Django] apple social login 구현기 3일차 + async openai in django (7) | 2024.10.19 |
[Django] 회원탈퇴 기능 + 애플 소셜 로그인 구현하기 (4) | 2024.10.17 |
[Django] async openai 사용해보기 (0) | 2024.10.14 |
[Django] Lexorank 적용기 2일차 (1) | 2024.10.12 |