iOS

[SwiftUI] [Combine] [MVVM] Apple OAuth 소셜 로그인 구현하기

sujileelea 2023. 12. 16. 12:26

- 우리 서버 백엔드 설명

 

[Spring] FeignClient를 이용한 Apple OAuth 구현 일지 (1)

해당 글에서는 spring boot에서의 FeignClient 선택 이유 및 연동 프로덕션 및 테스트 코드, Apple OAuth 이론에 대해 다룹니다. 사이드 프로젝트 `모카콩`의 Wiki에 작성한 글에 해당된다. 해당 프로젝트 git

kth990303.tistory.com

- 애플 공식 문서

 

Authentication Services | Apple Developer Documentation

Make it easy for users to log into apps and services.

developer.apple.com

 

 

Apple OAuth 작동 시퀀스

  1. 클라이언트가 애플 서버로 사용자에 대한 credential 요청
  2. 수신한 credential에서 JWT token 형식의 identity token 추출
  3. 추출한 identity token을 우리 서버로 송신
  4. 우리 서버에서 해당 identity token으로 애플 서버에 사용자 식별 요청
  5. 필요 시, authorization code와 identity token을 바탕으로 우리 서버가 애플 서버에 access token과 refresh token 발급 요청 가능
  6. 우리 서버에서 클라이언트로 사용자에 대한 access token과 refresh token을 송신

이 글에서 소개할 방식은 5번 과정을 생략한다. 대신 사용자 식별 후 우리 서버에서 사용자에 대한 자체 access token과 refresh token을 포함한 JWT 토큰을 발행해 클라이언트로 넘겨주는 방법에 대해서 소개한다.

이 글에서는 iOS에 대해서만 다루며, 백엔드에 대한 글은 상단 링크를 참조

Apple 서버에 credentials 및 user info 요청

먼저 로그인 기능 사용을 위한 라이브러리를 import 한 뒤 Model과 ViewModel을 만들어주고 ASAuthorizationControllerDelegate 프로토콜과 ASAuthorizationControllerPresentationContextProviding을 구현한다.

 import AuthenticationServices

Model

 //Model
 struct AppleLoginInfo: Codable {
     var token: String?
 }

ViewModel

loginApple()를 호출하는 것으로 애플 로그인 시퀀스가 시작된다. 애플 로그인 시 보안을 위한 권고 사항 중 하나인 nonce값도 request에 포함시켰다. 함수 authorizationController()에서 credential의 identity token을 추출해 함수 insertAppleIdTokenToAppleLoginInfoModel()의 인자로 넘겨주고 있다.

 class AppleLoginViewModel: NSObject, ObservableObject {
    var appleLoginInfo = AppleLoginInfo() 

     func loginApple() {
     // 애플 로그인 요청 시 사용되는 요청 객체 생성
     let request = ASAuthorizationAppleIDProvider().createRequest()
     request.requestedScopes = [.fullName, .email]
     request.nonce = encdoeNonceSha256(nonce)

     // 인증 요청 컨트롤러 생성
     let controller = ASAuthorizationController(authorizationRequests: [request])
     controller.delegate = self
     controller.presentationContextProvider = self
     controller.performRequests()
     }
 }
 ​
 // ASAuthorizationControllerDelegate 프로토콜 구현
 extension MemberViewModel: ASAuthorizationControllerDelegate {

     // 인증이 성공적으로 완료되었을 때 호출되는 콜백 함수
     func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
         if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
             insertAppleIdTokenToAppleLoginInfoModel(appleIdToken: String(decoding: appleIDCredential.identityToken!, as: UTF8.self))
             print("\(appleIDCredential.user)의 인증서 발급 성공")
         }
     }
 ​
     func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
         self.error = error
         print("인증(로그인) 실패 error : \(error)")
     }
 }
 ​
 // ASAuthorizationControllerPresentationContextProviding 구현
 extension MemberViewModel: ASAuthorizationControllerPresentationContextProviding {
     func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
         // 로그인 화면이 표시될 컨텍스트를 제공
         guard let window = UIApplication.shared.windows.first else {
             fatalError("No window found.")
         }
         return window
     }
 }

추출한 identity token으로 우리 서버에 애플 로그인 요청

appleLoginInfo 프로퍼티에 추출한 identity token을 담고 이를 우리 서버에 애플 로그인을 요청하는 함수 requestAppleLoginToServer()의 인자로 넘겨준다.

 func insertAppleIdTokenToAppleLoginModel(appleIdToken: String) {
     appleLoginInfo.token = appleIdToken
     if appleLoginInfo.token != nil {
 //     print("애플 토큰 : ", appleLoginInfo.token)
         requestAppleLoginToServer(appleLoginInfo: appleLoginInfo)
             .sink(receiveCompletion: { result in
                 switch result {
                 case .failure(let error):
                     print("애플 로그인 비동기 error: \(error)")
                 case .finished:
                     print("애플 로그인 비동기 종료")
                     break
                 }
             }, receiveValue: { member in
             })
             .store(in: &self.loginCancellables)
     }
 }
 ​

함수 reqeustAppleLoginToServer() 는 비동기로 작업을 수행하고, 만약 우리 서버에 보내는 identity 토큰 형식이 유효하지 않을 경우에는 이를 처리하는 로직을 추가하면 된다.

 func requestAppleLoginToServer(appleLoginInfo: AppleLoginInfo) -> Future<{반환할 타입}, Error> {
     return Future { promise in
         guard let url = URL(string: "{엔드포인트}") else {
             fatalError("Invalid URL")
         }
         var request = URLRequest(url: url)
         request.httpMethod = "POST"
         do {
             let jsonData = try JSONEncoder().encode(appleLoginInfo)
             request.httpBody = jsonData
             request.addValue("application/json", forHTTPHeaderField: "Content-Type")
         } catch {
             promise(.failure(error))
         }
 ​
         URLSession.shared.dataTaskPublisher(for: request)
             .subscribe(on: DispatchQueue.global(qos: .background))
             .receive(on: DispatchQueue.main)
             .tryMap { data, response -> Data in
                 guard let httpResponse = response as? HTTPURLResponse else {
                     throw URLError(.badServerResponse)
                 }
                if httpResponse.statusCode != 200 {
                     throw URLError(.badServerResponse)
                     print("유효하지 않은 apple identity token")
                 }
                 return data
             }
             .decode(type: {디코드할 타입}.self, decoder: JSONDecoder())
             .sink { completion in
                 print("Completion: \(completion)")
                 promise(.success({성공시 반환할 인스턴스}))
             } receiveValue: { data in
 //우리 서버로부터 수신한 data 처리
             }
             .store(in: &self.loginCancellables)
     }
 }

로그인 View 구현

이제 함수 loginApple()을 호출할 로그인 뷰를 구현해보자. 애플은 애플 로그인 아이콘에 대한 가이드와 기본 인터페이스를 제공하며 이는 공식 문서에서 확인 가능하다. 나는 직접 만듦.

View

 import SwiftUI
 ​
 struct LoginView: View {

     @ObservedObject var appleLoginViewModel: AppleLoginViewModel = AppleLoginViewModel

     var body: some View {
         ZStack {
             Rectangle()
                 .foregroundColor(Color.ivory)
             VStack {
                 Button(action: {
                     memberVM.loginApple()
                 }, label: {
                     appleDefaultIcon()
                 })
             }
         }
         .ignoresSafeArea()
     }

     @ViewBuilder
     func appleDefaultIcon() -> some View {
         RoundedRectangle(cornerRadius: 6.3)
             .frame(width: screenWidth * 0.8, height: 48)
             .foregroundColor(.black)
             .overlay(
                 HStack {
                     Image(systemName: "apple.logo")
                         .foregroundColor(.white)
                         .font(.system(size: 19))
                         .offset(y:-1.5)
                     Text("Continue with Apple")
                         .foregroundColor(.white)
                         .font(.system(size: 17))
                 }
             )
     }
 }
 ​