diff --git a/SodaLive/Sources/User/Login/LoginView.swift b/SodaLive/Sources/User/Login/LoginView.swift index 62014ae..a0e8d0b 100644 --- a/SodaLive/Sources/User/Login/LoginView.swift +++ b/SodaLive/Sources/User/Login/LoginView.swift @@ -138,7 +138,7 @@ struct LoginView: View { Image("ic_login_apple") .onTapGesture { hideKeyboard() - AppState.shared.setAppStep(step: .signUp) + viewModel.loginWithApple() } } .padding(.top, 20) diff --git a/SodaLive/Sources/User/Login/LoginViewModel.swift b/SodaLive/Sources/User/Login/LoginViewModel.swift index 512ebe3..0467209 100644 --- a/SodaLive/Sources/User/Login/LoginViewModel.swift +++ b/SodaLive/Sources/User/Login/LoginViewModel.swift @@ -8,11 +8,16 @@ import Foundation import Moya import Combine +import AuthenticationServices +import CryptoKit +import Security +import UIKit -final class LoginViewModel: ObservableObject { +final class LoginViewModel: NSObject, ObservableObject { private let appViewModel = AppViewModel() private let repository = UserRepository() private var subscription = Set() + private var currentNonce: String? @Published var email = "" @Published var password = "" @@ -41,38 +46,166 @@ final class LoginViewModel: ObservableObject { case .finished: DEBUG_LOG("finish") case .failure(let error): + self.isLoading = false ERROR_LOG(error.localizedDescription) } } receiveValue: { response in - self.isLoading = false - let responseData = response.data - do { - let jsonDecoder = JSONDecoder() - let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) - - if let data = decoded.data, decoded.success { - UserDefaults.set(data.profileImage, forKey: .profileImage) - UserDefaults.set(data.nickname, forKey: .nickname) - UserDefaults.set(data.userId, forKey: .userId) - UserDefaults.set(data.email, forKey: .email) - UserDefaults.set(data.token, forKey: .token) - self.appViewModel.fetchAndUpdateIdfa() - AppState.shared.isRestartApp = true - AppState.shared.setAppStep(step: .splash) - } else { - if let message = decoded.message { - self.errorMessage = message - } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." - } - - self.isShowPopup = true - } - } catch { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." - self.isShowPopup = true - } + self.handleLoginResponse(response) } .store(in: &subscription) } + + func loginWithApple() { + let nonce = randomNonceString() + currentNonce = nonce + + let request = ASAuthorizationAppleIDProvider().createRequest() + request.requestedScopes = [.email] + request.nonce = hashNonce(nonce) + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + + private func loginWithApple(identityToken: String, nonce: String) { + let pushToken = UserDefaults.string(forKey: .pushToken) + let marketingPid = UserDefaults.string(forKey: .marketingPid) + let request = SocialLoginRequest( + container: "ios", + pushToken: pushToken.isEmpty ? nil : pushToken, + marketingPid: marketingPid.isEmpty ? nil : marketingPid, + identityToken: identityToken, + nonce: nonce + ) + + isLoading = true + repository.loginApple(request: request) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + self.isLoading = false + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + self.handleLoginResponse(response) + } + .store(in: &subscription) + } + + private func handleLoginResponse(_ response: Response) { + self.isLoading = false + let responseData = response.data + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + UserDefaults.set(data.profileImage, forKey: .profileImage) + UserDefaults.set(data.nickname, forKey: .nickname) + UserDefaults.set(data.userId, forKey: .userId) + UserDefaults.set(data.email, forKey: .email) + UserDefaults.set(data.token, forKey: .token) + self.appViewModel.fetchAndUpdateIdfa() + AppState.shared.isRestartApp = true + AppState.shared.setAppStep(step: .splash) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + + private func randomNonceString(length: Int = 32) -> String { + precondition(length > 0) + let charset = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + var result = "" + var remainingLength = length + + while remainingLength > 0 { + var randoms = [UInt8](repeating: 0, count: 16) + let errorCode = SecRandomCopyBytes(kSecRandomDefault, randoms.count, &randoms) + if errorCode != errSecSuccess { + return UUID().uuidString + } + + for random in randoms { + if remainingLength == 0 { + break + } + + if Int(random) < charset.count { + result.append(charset[Int(random)]) + remainingLength -= 1 + } + } + } + + return result + } + + private func hashNonce(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashed = SHA256.hash(data: inputData) + + let hashData = Data(hashed) + + return hashData + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +extension LoginViewModel: ASAuthorizationControllerDelegate { + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { + self.errorMessage = "애플 로그인 정보를 가져오지 못했습니다." + self.isShowPopup = true + return + } + + guard let identityTokenData = appleIDCredential.identityToken, + let identityToken = String(data: identityTokenData, encoding: .utf8) else { + self.errorMessage = "애플 인증 토큰을 가져오지 못했습니다." + self.isShowPopup = true + return + } + + guard let nonce = currentNonce else { + self.errorMessage = "다시 시도해 주세요." + self.isShowPopup = true + return + } + + currentNonce = nil + loginWithApple(identityToken: identityToken, nonce: nonce) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + ERROR_LOG(error.localizedDescription) + self.errorMessage = "애플 로그인에 실패했습니다.\n다시 시도해 주세요." + self.isShowPopup = true + } +} + +extension LoginViewModel: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } ?? ASPresentationAnchor() + } } diff --git a/SodaLive/Sources/User/Login/SocialLoginRequest.swift b/SodaLive/Sources/User/Login/SocialLoginRequest.swift new file mode 100644 index 0000000..6daaadc --- /dev/null +++ b/SodaLive/Sources/User/Login/SocialLoginRequest.swift @@ -0,0 +1,16 @@ +// +// SocialLoginRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/09. +// + +import Foundation + +struct SocialLoginRequest: Encodable { + let container: String + let pushToken: String? + let marketingPid: String? + let identityToken: String? + let nonce: String? +} diff --git a/SodaLive/Sources/User/UserApi.swift b/SodaLive/Sources/User/UserApi.swift index 6570f92..4d981ec 100644 --- a/SodaLive/Sources/User/UserApi.swift +++ b/SodaLive/Sources/User/UserApi.swift @@ -10,6 +10,7 @@ import Moya enum UserApi { case login(request: LoginRequest) + case loginApple(request: SocialLoginRequest) case signUp(request: SignUpRequest) case findPassword(request: ForgotPasswordRequest) case searchUser(nickname: String) @@ -47,6 +48,9 @@ extension UserApi: TargetType { case .login: return "/member/login" + case .loginApple: + return "/member/login/apple" + case .signUp: return "/member/signup/v2" @@ -120,7 +124,7 @@ extension UserApi: TargetType { var method: Moya.Method { switch self { - case .login, .signUp, .findPassword, .notification, .logout, .logoutAllDevice, .signOut, .creatorFollow, .creatorUnFollow, .memberBlock, .memberUnBlock, + case .login, .loginApple, .signUp, .findPassword, .notification, .logout, .logoutAllDevice, .signOut, .creatorFollow, .creatorUnFollow, .memberBlock, .memberUnBlock, .profileImageUpdate: return .post @@ -137,6 +141,9 @@ extension UserApi: TargetType { case .login(let request): return .requestJSONEncodable(request) + case .loginApple(let request): + return .requestJSONEncodable(request) + case .signUp(let request): return .requestJSONEncodable(request) @@ -199,7 +206,7 @@ extension UserApi: TargetType { var headers: [String : String]? { switch self { - case .login, .signUp, .findPassword: + case .login, .loginApple, .signUp, .findPassword: return nil default: diff --git a/SodaLive/Sources/User/UserRepository.swift b/SodaLive/Sources/User/UserRepository.swift index b8b7c1d..d707b1c 100644 --- a/SodaLive/Sources/User/UserRepository.swift +++ b/SodaLive/Sources/User/UserRepository.swift @@ -17,6 +17,10 @@ final class UserRepository { return api.requestPublisher(.login(request: request)) } + func loginApple(request: SocialLoginRequest) -> AnyPublisher { + return api.requestPublisher(.loginApple(request: request)) + } + func signUp(request: SignUpRequest) -> AnyPublisher { return api.requestPublisher(.signUp(request: request)) }