From 9bf33a694ae028816024997bc74cb5be01a7261f Mon Sep 17 00:00:00 2001 From: hyun subin Date: Wed, 15 Jan 2025 15:20:15 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20-=20#44=20=EC=95=A0=ED=94=8C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Entitlements/OnboardingKit.entitlements | 16 +-- .../Sources/Models/SocialLoginInfo.swift | 31 ++++++ .../Sources/Services/Login/AppleLogin.swift | 93 ++++++++++++++++++ .../Sources/Services/Login/KakaoLogin.swift | 98 +++++++++++++++++++ .../Services/Login/SocialLoginClient.swift | 48 +++++++++ .../Sources/Services/Login/TokenInfo.swift | 18 ++++ .../Feature/Login/Sources/LoginStore.swift | 80 ++++++++++++--- .../Feature/Login/Sources/LoginView.swift | 1 + 8 files changed, 366 insertions(+), 19 deletions(-) create mode 100644 Projects/Core/Domain/Sources/Models/SocialLoginInfo.swift create mode 100644 Projects/Core/Network/Sources/Services/Login/AppleLogin.swift create mode 100644 Projects/Core/Network/Sources/Services/Login/KakaoLogin.swift create mode 100644 Projects/Core/Network/Sources/Services/Login/SocialLoginClient.swift create mode 100644 Projects/Core/Network/Sources/Services/Login/TokenInfo.swift diff --git a/Projects/App/Entitlements/OnboardingKit.entitlements b/Projects/App/Entitlements/OnboardingKit.entitlements index 251a256..fd1c726 100644 --- a/Projects/App/Entitlements/OnboardingKit.entitlements +++ b/Projects/App/Entitlements/OnboardingKit.entitlements @@ -1,10 +1,14 @@ - - keychain-access-groups - - $(AppIdentifierPrefix)group.com.DDD.OnboardingKit - - + + com.apple.developer.applesignin + + Default + + keychain-access-groups + + $(AppIdentifierPrefix)group.com.DDD.OnboardingKit + + diff --git a/Projects/Core/Domain/Sources/Models/SocialLoginInfo.swift b/Projects/Core/Domain/Sources/Models/SocialLoginInfo.swift new file mode 100644 index 0000000..7b25c1a --- /dev/null +++ b/Projects/Core/Domain/Sources/Models/SocialLoginInfo.swift @@ -0,0 +1,31 @@ +// +// SocialLoginInfo.swift +// CoreDomain +// +// Created by 현수빈 on 1/15/25. +// + +import Foundation + +public struct SocialLoginInfo: Equatable { + public let idToken: String + public let nonce: String? + public let provider: Socialtype + + public init( + idToken: String, + nonce: String? = nil, + provider: Socialtype + ) { + self.idToken = idToken + self.nonce = nonce + self.provider = provider + } +} + +extension SocialLoginInfo { + public enum Socialtype: String { + case kakao = "Kakao" + case apple = "Apple" + } +} diff --git a/Projects/Core/Network/Sources/Services/Login/AppleLogin.swift b/Projects/Core/Network/Sources/Services/Login/AppleLogin.swift new file mode 100644 index 0000000..c38a663 --- /dev/null +++ b/Projects/Core/Network/Sources/Services/Login/AppleLogin.swift @@ -0,0 +1,93 @@ +// +// SocialLogin.swift +// CoreNetwork +// +// Created by 현수빈 on 1/15/25. +// + +import AuthenticationServices +import Foundation + +import CoreDomain + +public enum AppleErrorType: Error { + case invalidToken + case invalidAuthorizationCode + case dismissASAuthorizationController +} + +final class AppleLogin: NSObject, ASAuthorizationControllerDelegate { + private var continuation: CheckedContinuation? = nil + + /// 애플 로그인 + @MainActor + func appleLogin() async throws -> SocialLoginInfo { + return try await withCheckedThrowingContinuation { continuation in + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + request.requestedScopes = [.fullName, .email] + + let authorizationController = ASAuthorizationController(authorizationRequests: [request]) + authorizationController.delegate = self + authorizationController.performRequests() + + if self.continuation == nil { + self.continuation = continuation + } + } + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + switch authorization.credential { + case let appleIDCredential as ASAuthorizationAppleIDCredential: + let email = appleIDCredential.email + debugPrint("appleLogin email: \(email ?? "")") + let fullName = appleIDCredential.fullName + debugPrint("appleLogin fullName: \(fullName?.description ?? "")") + + guard let tokenData = appleIDCredential.identityToken, + let token = String(data: tokenData, encoding: .utf8) else { + continuation?.resume(throwing: AppleErrorType.invalidToken) + continuation = nil + return + } + + debugPrint("appleLogin token: \(token)") + + guard let authorizationCode = appleIDCredential.authorizationCode, + let authorizationCodeString = String(data: authorizationCode, encoding: .utf8) else { + continuation?.resume(throwing: AppleErrorType.invalidAuthorizationCode) + continuation = nil + return + } + + debugPrint("appleLogin authorizationCode: \(authorizationCodeString)") + + let userIdentifier = appleIDCredential.user + debugPrint("appleLogin authenticated user: \(userIdentifier)") + + let info = SocialLoginInfo(idToken: token, provider: .apple) + + continuation?.resume(returning: info) + continuation = nil + + default: + break + } + } + + @MainActor + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + if let authError = error as? ASAuthorizationError { + switch authError.code { + case .canceled: + continuation?.resume(throwing: AppleErrorType.dismissASAuthorizationController) + continuation = nil + + default: + continuation?.resume(throwing: authError) + continuation = nil + } + } + } +} diff --git a/Projects/Core/Network/Sources/Services/Login/KakaoLogin.swift b/Projects/Core/Network/Sources/Services/Login/KakaoLogin.swift new file mode 100644 index 0000000..8a6e372 --- /dev/null +++ b/Projects/Core/Network/Sources/Services/Login/KakaoLogin.swift @@ -0,0 +1,98 @@ +// +// KakaoLogin.swift +// CoreNetwork +// +// Created by 현수빈 on 1/15/25. +// + +import Foundation + +import CoreDomain + +import KakaoSDKAuth +import KakaoSDKCommon +import KakaoSDKUser + +enum KakaoErrorType: Error { + case invalidToken +} + +final class KakaoLogin { + private var continuation: CheckedContinuation? = nil + + + func initSDK() { + if let appkey = Bundle.main.infoDictionary?["KAKAO_NATIVE_APP_KEY"] as? String { + KakaoSDK.initSDK(appKey: appkey) + } + } + + /// Handle KakaoTalkLoginUrl + func handleKakaoTalkLoginUrl(url: URL) { + guard AuthApi.isKakaoTalkLoginUrl(url) else { return } + _ = AuthController.handleOpenUrl(url: url) + } + + /// 카카오톡 로그인 + @MainActor + func kakaoLogin() async throws -> SocialLoginInfo { + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + let nonce = UUID().uuidString + + if UserApi.isKakaoTalkLoginAvailable() { + loginWithKakaoTalk(nonce: nonce) + } else { + loginWithKakaoWeb(nonce: nonce) + } + } + } + + /// 카카오톡(앱)으로 로그인 + private func loginWithKakaoTalk(nonce: String) { + UserApi.shared.loginWithKakaoTalk(nonce: nonce) { [weak self] OAuthToken, error in + guard let self else { return } + + if let error { + self.continuation?.resume(throwing: error) + self.continuation = nil + debugPrint("\(error)") + return + } else if let token = OAuthToken?.idToken { + self.setSocialLoginData(idToken: token, nonce: nonce) + debugPrint("loginWithKakaoTalk() success., \(#function), \(#line)") + } else { + self.continuation?.resume(throwing: KakaoErrorType.invalidToken) + self.continuation = nil + return + } + } + } + + /// 웹에서 카카오 계정 Access + private func loginWithKakaoWeb(nonce: String) { + UserApi.shared.loginWithKakaoAccount(nonce: nonce) { [weak self] OAuthToken, error in + guard let self else { return } + + if let error { + self.continuation?.resume(throwing: error) + self.continuation = nil + debugPrint("\(error)") + return + } else if let token = OAuthToken?.idToken { + self.setSocialLoginData(idToken: token, nonce: nonce) + debugPrint("loginWithWeb() success., \(#function), \(#line)") + } else { + self.continuation?.resume(throwing: KakaoErrorType.invalidToken) + self.continuation = nil + return + } + } + } + + private func setSocialLoginData(idToken: String, nonce: String) { + let info = SocialLoginInfo(idToken: idToken, nonce: nonce, provider: .kakao) + continuation?.resume(returning: info) + continuation = nil + } +} diff --git a/Projects/Core/Network/Sources/Services/Login/SocialLoginClient.swift b/Projects/Core/Network/Sources/Services/Login/SocialLoginClient.swift new file mode 100644 index 0000000..202f7b8 --- /dev/null +++ b/Projects/Core/Network/Sources/Services/Login/SocialLoginClient.swift @@ -0,0 +1,48 @@ +// +// SocialLoginClient.swift +// CoreNetwork +// +// Created by 현수빈 on 1/15/25. +// + +import Foundation + +import CoreDomain + +import ComposableArchitecture + +public struct SocialLoginClient { + public var initKakaoSDK: @Sendable () -> Void + public var handleKakaoUrl: @Sendable (URL) -> Void + public var kakaoLogin: @Sendable () async throws -> SocialLoginInfo + public var appleLogin: @Sendable () async throws -> SocialLoginInfo +} + +extension SocialLoginClient: DependencyKey { + public static var liveValue: SocialLoginClient { + let kakaoLogin = KakaoLogin() + let appleLogin = AppleLogin() + + return Self( + initKakaoSDK: { + kakaoLogin.initSDK() + }, + handleKakaoUrl: { + kakaoLogin.handleKakaoTalkLoginUrl(url: $0) + }, + kakaoLogin: { + try await kakaoLogin.kakaoLogin() + }, + appleLogin: { + try await appleLogin.appleLogin() + } + ) + } +} + +public extension DependencyValues { + var socialLogin: SocialLoginClient { + get { self[SocialLoginClient.self] } + set { self[SocialLoginClient.self] = newValue } + } +} diff --git a/Projects/Core/Network/Sources/Services/Login/TokenInfo.swift b/Projects/Core/Network/Sources/Services/Login/TokenInfo.swift new file mode 100644 index 0000000..eb1f299 --- /dev/null +++ b/Projects/Core/Network/Sources/Services/Login/TokenInfo.swift @@ -0,0 +1,18 @@ +// +// TokenInfo.swift +// CoreNetwork +// +// Created by 현수빈 on 1/15/25. +// + +import Foundation + +public struct TokenInfo { + public let accessToken: String + public let refreshToken: String + + public init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } +} diff --git a/Projects/Feature/Login/Sources/LoginStore.swift b/Projects/Feature/Login/Sources/LoginStore.swift index 6fc4c1f..2ec876b 100644 --- a/Projects/Feature/Login/Sources/LoginStore.swift +++ b/Projects/Feature/Login/Sources/LoginStore.swift @@ -5,6 +5,7 @@ // Created by 현수빈 on 1/8/25. // +import AuthenticationServices import Foundation import CoreCommon @@ -19,46 +20,99 @@ public struct LoginStore { public init() { } public struct State { - var isLoading: Bool = false + var isLoading = false + var isLoggedIn = false + var user: UserInfo? = nil public init() { } } public enum Action { case didTapKakaoLogin case didTapAppleLogin + case loginSuccess(UserInfo) + case loginFailure case didTapGuestLogin + case setLoading(Bool) case routeToOnboardingScreen } - + @Dependency(\.socialLogin) private var socialLogin + public var body: some ReducerOf { - Reduce { state, action in + Reduce { state,action in switch action { case .didTapGuestLogin: return .send(.routeToOnboardingScreen) + case .routeToOnboardingScreen: return .none case .didTapKakaoLogin: if (UserApi.isKakaoTalkLoginAvailable()) { - UserApi.shared.loginWithKakaoTalk {(oauthToken, error) in - if let error = error { - print(error) - } - else { - print("loginWithKakaoTalk() success.") - - //do something - _ = oauthToken - } + return .run( + operation: { send in + await send(.setLoading(true)) + + let info = try await socialLogin.kakaoLogin() + let user = UserInfo( + userID: info.idToken, + nickName: "", + job: .developer, + workExperience: 0 + ) + await send(.loginSuccess(user)) + }, + catch: { error, send in + debugPrint(error) + await send(.setLoading(false)) + await send(.loginFailure) } + ) + } else { + return .none } + case let .loginSuccess(user): + state.isLoggedIn = true + state.user = user + return .none + + case .loginFailure: return .none + case .didTapAppleLogin: + return .run( + operation: { send in + await send(.setLoading(true)) + let info = try await socialLogin.appleLogin() + let user = UserInfo( + userID: info.idToken, + nickName: "", + job: .developer, + workExperience: 0 + ) + await send(.loginSuccess(user)) + }, + catch: { error, send in + await send(.setLoading(false)) + + guard let appleAuthError = error as? AppleErrorType else { + await send(.loginFailure) + return + } + + if case .dismissASAuthorizationController = appleAuthError { + return + } + + await send(.loginFailure) + } + ) + default: return .none } } } } + diff --git a/Projects/Feature/Login/Sources/LoginView.swift b/Projects/Feature/Login/Sources/LoginView.swift index 8dc36e5..1df989a 100644 --- a/Projects/Feature/Login/Sources/LoginView.swift +++ b/Projects/Feature/Login/Sources/LoginView.swift @@ -6,6 +6,7 @@ // Copyright © 2025 DDD , Ltd., All rights reserved. // +import AuthenticationServices import SwiftUI import SharedDesignSystem