diff --git a/KkuMulKum.xcodeproj/project.pbxproj b/KkuMulKum.xcodeproj/project.pbxproj index dd4d3d0a..36e4a98a 100644 --- a/KkuMulKum.xcodeproj/project.pbxproj +++ b/KkuMulKum.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 789D73A72C46AF4900C7077D /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 789D73A62C46AF4900C7077D /* KeychainService.swift */; }; 789D73AF2C46D99B00C7077D /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 789D73AE2C46D99B00C7077D /* GoogleService-Info.plist */; }; 789D73B32C47CC6D00C7077D /* LocalNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 789D73B22C47CC6D00C7077D /* LocalNotificationManager.swift */; }; + 789D73BE2C47FE0F00C7077D /* AuthInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 789D73BD2C47FE0F00C7077D /* AuthInterceptor.swift */; }; 78AED1342C3D951F000AD80A /* NicknameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AED1332C3D951F000AD80A /* NicknameViewController.swift */; }; 78AED1372C3D98D1000AD80A /* NicknameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78AED1362C3D98D1000AD80A /* NicknameView.swift */; }; 78B9286C2C29402C006D9942 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78B9286B2C29402C006D9942 /* AppDelegate.swift */; }; @@ -245,6 +246,7 @@ 789D73AE2C46D99B00C7077D /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 789D73B02C46DACD00C7077D /* KkuMulKum.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KkuMulKum.entitlements; sourceTree = ""; }; 789D73B22C47CC6D00C7077D /* LocalNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNotificationManager.swift; sourceTree = ""; }; + 789D73BD2C47FE0F00C7077D /* AuthInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthInterceptor.swift; sourceTree = ""; }; 78AED1332C3D951F000AD80A /* NicknameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameViewController.swift; sourceTree = ""; }; 78AED1362C3D98D1000AD80A /* NicknameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NicknameView.swift; sourceTree = ""; }; 78B928682C29402C006D9942 /* KkuMulKum.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KkuMulKum.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -542,6 +544,14 @@ path = Notification; sourceTree = ""; }; + 789D73BC2C47FDEE00C7077D /* Auth */ = { + isa = PBXGroup; + children = ( + 789D73BD2C47FE0F00C7077D /* AuthInterceptor.swift */, + ); + path = Auth; + sourceTree = ""; + }; 78AED1322C3D9514000AD80A /* Nickname */ = { isa = PBXGroup; children = ( @@ -1066,6 +1076,7 @@ DDA2EE7E2C3860B2007C6059 /* Core */ = { isa = PBXGroup; children = ( + 789D73BC2C47FDEE00C7077D /* Auth */, DDA2EE722C385EB9007C6059 /* MainTabBarController.swift */, ); path = Core; @@ -1744,6 +1755,7 @@ DD3F9DD02C48571A008E1FF7 /* MeetingListServiceType.swift in Sources */, DE6D4D132C3F14D80005584B /* MeetingMemberCell.swift in Sources */, DDAF1C932C3D6E3D008A37D3 /* PagePromiseViewController.swift in Sources */, + 789D73BE2C47FE0F00C7077D /* AuthInterceptor.swift in Sources */, 789D73B32C47CC6D00C7077D /* LocalNotificationManager.swift in Sources */, DE9E18922C3BCC9D00DB76B4 /* SocialLoginRequestModel.swift in Sources */, DE254AA82C3118EA00A4015E /* UIView+.swift in Sources */, @@ -1994,6 +2006,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = D2DRA3F792; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = KkuMulKum/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "꾸물꿈"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -2027,6 +2040,7 @@ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = D2DRA3F792; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = KkuMulKum/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "꾸물꿈"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; diff --git a/KkuMulKum/Application/AppDelegate.swift b/KkuMulKum/Application/AppDelegate.swift index 24d74838..467001bc 100644 --- a/KkuMulKum/Application/AppDelegate.swift +++ b/KkuMulKum/Application/AppDelegate.swift @@ -19,7 +19,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - sleep(1) //런치스크린 작동용 // KakaoSDK 초기화 과정에서 앱 키를 동적으로 불러오기 if let kakaoAppKey = fetchKakaoAppKeyFromPrivacyInfo() { KakaoSDK.initSDK(appKey: kakaoAppKey) diff --git a/KkuMulKum/Application/SceneDelegate.swift b/KkuMulKum/Application/SceneDelegate.swift index e9b59113..2af0dffc 100644 --- a/KkuMulKum/Application/SceneDelegate.swift +++ b/KkuMulKum/Application/SceneDelegate.swift @@ -6,10 +6,12 @@ // import UIKit + import KakaoSDKAuth class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + let loginViewModel = LoginViewModel() func scene( _ scene: UIScene, @@ -18,10 +20,31 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) { guard let windowScene = (scene as? UIWindowScene) else { return } self.window = UIWindow(windowScene: windowScene) - self.window?.rootViewController = LoginViewController() + + let launchScreenStoryboard = UIStoryboard(name: "LaunchScreen", bundle: nil) + let launchScreenViewController = launchScreenStoryboard.instantiateInitialViewController() + + self.window?.rootViewController = launchScreenViewController self.window?.makeKeyAndVisible() + + performAutoLogin() } + private func performAutoLogin() { + print("Performing auto login") + loginViewModel.autoLogin { [weak self] success in + DispatchQueue.main.async { + if success { + print("Auto login successful, showing main screen") + self?.showLoginScreen() + } else { + print("Auto login failed, showing login screen") + self?.showLoginScreen() + } + } + } + } + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { if let url = URLContexts.first?.url { if (AuthApi.isKakaoTalkLoginUrl(url)) { @@ -41,6 +64,33 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { return false } + private func showMainScreen() { + let mainTabBarController = MainTabBarController() + let navigationController = UINavigationController(rootViewController: mainTabBarController) + navigationController.isNavigationBarHidden = true + + animateRootViewControllerChange(to: navigationController) + } + + private func showLoginScreen() { + let loginViewController = LoginViewController() + animateRootViewControllerChange(to: loginViewController) + } + + private func animateRootViewControllerChange(to newRootViewController: UIViewController) { + guard let window = self.window else { return } + + UIView.transition(with: window, + duration: 0.3, + options: .transitionCrossDissolve, + animations: { + let oldState = UIView.areAnimationsEnabled + UIView.setAnimationsEnabled(false) + window.rootViewController = newRootViewController + UIView.setAnimationsEnabled(oldState) + }) + } + func sceneDidDisconnect(_ scene: UIScene) {} func sceneDidBecomeActive(_ scene: UIScene) {} func sceneWillResignActive(_ scene: UIScene) {} diff --git a/KkuMulKum/Network/TargetType/LoginTargetType.swift b/KkuMulKum/Network/TargetType/LoginTargetType.swift index 0ea276f7..a4d20196 100644 --- a/KkuMulKum/Network/TargetType/LoginTargetType.swift +++ b/KkuMulKum/Network/TargetType/LoginTargetType.swift @@ -4,7 +4,6 @@ // // Created by 이지훈 on 7/15/24. // - import Foundation import Moya @@ -16,7 +15,6 @@ enum LoginTargetType { } extension LoginTargetType: TargetType { - var method: Moya.Method { .post } @@ -42,15 +40,9 @@ extension LoginTargetType: TargetType { var task: Task { switch self { case let .appleLogin(_, fcmToken): - return .requestParameters( - parameters: ["provider": "APPLE", "fcmToken": fcmToken], - encoding: JSONEncoding.default - ) + return .requestJSONEncodable(SocialLoginRequestModel(provider: "APPLE", fcmToken: fcmToken)) case let .kakaoLogin(_, fcmToken): - return .requestParameters( - parameters: ["provider": "KAKAO", "fcmToken": fcmToken], - encoding: JSONEncoding.default - ) + return .requestJSONEncodable(SocialLoginRequestModel(provider: "KAKAO", fcmToken: fcmToken)) case .refreshToken: return .requestPlain } @@ -67,4 +59,3 @@ extension LoginTargetType: TargetType { } } } - diff --git a/KkuMulKum/Source/Core/Auth/AuthInterceptor.swift b/KkuMulKum/Source/Core/Auth/AuthInterceptor.swift new file mode 100644 index 00000000..51e18711 --- /dev/null +++ b/KkuMulKum/Source/Core/Auth/AuthInterceptor.swift @@ -0,0 +1,78 @@ +// +// AuthInterceptor.swift +// KkuMulKum +// +// Created by 이지훈 on 7/17/24. +// + +import Foundation + +import Moya +import Alamofire + +enum AuthError: Error { + case tokenRefreshFailed +} + +class AuthInterceptor: RequestInterceptor { + let authService: AuthServiceType + let provider: MoyaProvider + + init(authService: AuthServiceType, provider: MoyaProvider) { + self.authService = authService + self.provider = provider + } + + func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { + guard let accessToken = authService.getAccessToken() else { + completion(.success(urlRequest)) + return + } + + var urlRequest = urlRequest + urlRequest.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + completion(.success(urlRequest)) + } + + func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) { + guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401 else { + completion(.doNotRetry) + return + } + + guard let refreshToken = authService.getRefreshToken() else { + authService.clearTokens() + completion(.doNotRetry) + return + } + + provider.request(.refreshToken(refreshToken: refreshToken)) { [weak self] result in + switch result { + case .success(let response): + do { + let reissueResponse = try response.map(ResponseBodyDTO.self) + if reissueResponse.success, let data = reissueResponse.data { + let newAccessToken = data.accessToken + let newRefreshToken = data.refreshToken + self?.authService.saveAccessToken(newAccessToken) + self?.authService.saveRefreshToken(newRefreshToken) + print("Token refreshed successfully in interceptor") + completion(.retry) + } else { + print("Token refresh failed in interceptor: \(reissueResponse.error?.message ?? "Unknown error")") + self?.authService.clearTokens() + completion(.doNotRetry) + } + } catch { + print("Token refresh failed in interceptor: \(error)") + self?.authService.clearTokens() + completion(.doNotRetry) + } + case .failure(let error): + print("Network error during token refresh in interceptor: \(error)") + self?.authService.clearTokens() + completion(.doNotRetry) + } + } + } +} diff --git a/KkuMulKum/Source/Onboarding/Login/VIewModel/LoginViewModel.swift b/KkuMulKum/Source/Onboarding/Login/VIewModel/LoginViewModel.swift index d64a9068..e34b6209 100644 --- a/KkuMulKum/Source/Onboarding/Login/VIewModel/LoginViewModel.swift +++ b/KkuMulKum/Source/Onboarding/Login/VIewModel/LoginViewModel.swift @@ -11,6 +11,7 @@ import AuthenticationServices import KakaoSDKUser import KakaoSDKAuth import Moya +import FirebaseMessaging enum LoginState { case notLogin @@ -24,6 +25,7 @@ class LoginViewModel: NSObject { private let provider: MoyaProvider private var authService: AuthServiceType + private let authInterceptor: AuthInterceptor init( provider: MoyaProvider = MoyaProvider( @@ -33,6 +35,7 @@ class LoginViewModel: NSObject { ) { self.provider = provider self.authService = authService + self.authInterceptor = AuthInterceptor(authService: authService, provider: provider) super.init() } @@ -61,6 +64,22 @@ class LoginViewModel: NSObject { } } + private func getFCMToken(completion: @escaping (String) -> Void) { + Messaging.messaging().token { token, error in + if let error = error { + print("Error fetching FCM registration token: \(error)") + completion("fcm_token_not_available") + } else if let token = token { + print("Current FCM Token: \(token)") + UserDefaults.standard.set(token, forKey: "FCMToken") + UserDefaults.standard.synchronize() + completion(token) + } else { + completion("fcm_token_not_available") + } + } + } + private func handleKakaoLoginResult(oauthToken: OAuthToken?, error: Error?) { if let error = error { print("Kakao Login Error: \(error.localizedDescription)") @@ -70,7 +89,9 @@ class LoginViewModel: NSObject { if let token = oauthToken?.accessToken { print("Kakao Login Successful, access token: \(token)") - loginToServer(with: .kakaoLogin(accessToken: token, fcmToken: "dummy_fcm_token")) + getFCMToken { [weak self] fcmToken in + self?.loginToServer(with: .kakaoLogin(accessToken: token, fcmToken: fcmToken)) + } } else { print("Kakao Login Error: No access token") self.error.value = "No access token received" @@ -82,16 +103,13 @@ class LoginViewModel: NSObject { switch result { case .success(let response): print("Received response from server: \(response)") + print("Response body: \(String(data: response.data, encoding: .utf8) ?? "")") do { let loginResponse = try response.map( ResponseBodyDTO.self ) - print( - "Successfully mapped response: \(loginResponse)" - ) - self?.handleLoginResponse( - loginResponse - ) + print("Successfully mapped response: \(loginResponse)") + self?.handleLoginResponse(loginResponse) } catch { print("Failed to decode response: \(error)") self?.error.value = "Failed to decode response: \(error.localizedDescription)" @@ -105,35 +123,76 @@ class LoginViewModel: NSObject { } private func handleLoginResponse(_ response: ResponseBodyDTO) { - print("Handling login response") - if response.success { - if let data = response.data { - if data.name != nil { - print("Login successful") - loginState.value = .login - } else { - print("Login successful, but needs onboarding.") - loginState.value = .needOnboarding - } - - saveTokens( - accessToken: data.jwtTokenDTO.accessToken, - refreshToken: data.jwtTokenDTO.refreshToken - ) - } else { - print("Warning: No data received in response") - error.value = "No data received" - } - } else { - if let error = response.error { - print("Login failed: \(error.message)") - self.error.value = error.message - } else { - print("Login failed: Unknown error") - self.error.value = "Unknown error occurred" - } - } - } + print("Handling login response") + if response.success, let data = response.data { + saveTokens( + accessToken: data.jwtTokenDTO.accessToken, + refreshToken: data.jwtTokenDTO.refreshToken + ) + if data.name != nil { + print("Login successful") + loginState.value = .login + } else { + print("Login successful, but needs onboarding.") + loginState.value = .needOnboarding + } + } else { + if let error = response.error { + print("Login failed: \(error.message)") + self.error.value = error.message + } else { + print("Login failed: Unknown error") + self.error.value = "Unknown error occurred" + } + loginState.value = .notLogin + } + } + + func autoLogin(completion: @escaping (Bool) -> Void) { + guard let refreshToken = authService.getRefreshToken() else { + print("No refresh token found") + loginState.value = .notLogin + completion(false) + return + } + + print("Attempting auto login with refresh token") + provider.request(.refreshToken(refreshToken: refreshToken)) { [weak self] result in + switch result { + case .success(let response): + do { + let reissueResponse = try response.map(ResponseBodyDTO.self) + if reissueResponse.success, let data = reissueResponse.data { + let newAccessToken = data.accessToken + let newRefreshToken = data.refreshToken + self?.saveTokens(accessToken: newAccessToken, refreshToken: newRefreshToken) + self?.loginState.value = .login + print("Auto login successful") + completion(true) + } else { + print("Token refresh failed: \(reissueResponse.error?.message ?? "Unknown error")") + self?.clearTokensAndHandleError() + completion(false) + } + } catch { + print("Token refresh failed: \(error)") + self?.clearTokensAndHandleError() + completion(false) + } + case .failure(let error): + print("Network error during auto login: \(error)") + self?.clearTokensAndHandleError() + completion(false) + } + } + } + + private func clearTokensAndHandleError() { + authService.clearTokens() + loginState.value = .notLogin + error.value = "자동 로그인 실패. 다시 로그인해주세요." + print("Tokens cleared, login state set to notLogin") + } private func saveTokens(accessToken: String, refreshToken: String) { print("Attempting to save tokens") @@ -150,31 +209,26 @@ class LoginViewModel: NSObject { } } -extension LoginViewModel: ASAuthorizationControllerDelegate, - ASAuthorizationControllerPresentationContextProviding { +extension LoginViewModel: ASAuthorizationControllerDelegate, + ASAuthorizationControllerPresentationContextProviding { func authorizationController( controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization ) { - print( - "Apple authorization completed" - ) + print("Apple authorization completed") guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential, let identityToken = appleIDCredential.identityToken, - let tokenString = String( - data: identityToken, - encoding: .utf8 - ) else { - print( - "Failed to get Apple ID Credential or identity token" - ) + let tokenString = String(data: identityToken, encoding: .utf8) else { + print("Failed to get Apple ID Credential or identity token") return } - + print("Apple Login Successful, identity token: \(tokenString)") - loginToServer(with: .appleLogin(identityToken: tokenString, fcmToken: "dummy_fcm_token")) + getFCMToken { [weak self] fcmToken in + self?.loginToServer(with: .appleLogin(identityToken: tokenString, fcmToken: fcmToken)) + } } - + func authorizationController( controller: ASAuthorizationController, didCompleteWithError error: Error @@ -184,7 +238,7 @@ extension LoginViewModel: ASAuthorizationControllerDelegate, ) self.error.value = error.localizedDescription } - + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { print("Providing presentation anchor for Apple Login") let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene diff --git a/KkuMulKum/Source/Onboarding/Login/View/LoginView.swift b/KkuMulKum/Source/Onboarding/Login/View/LoginView.swift index 78f438b0..c130e98f 100644 --- a/KkuMulKum/Source/Onboarding/Login/View/LoginView.swift +++ b/KkuMulKum/Source/Onboarding/Login/View/LoginView.swift @@ -17,14 +17,6 @@ class LoginView: BaseView { $0.image = .imgLogin } - // TODO: 서버 연결후 삭제예정 - let dummyNextButton = UIButton().then { - $0.setTitle("다음 화면으로 (서버연결후 삭제예정)", for: .normal) - $0.setTitleColor(.white, for: .normal) - $0.backgroundColor = .blue - $0.layer.cornerRadius = 8 - } - let appleLoginImageView = UIImageView().then { $0.contentMode = .scaleAspectFit $0.image = UIImage(named: "appleLogin") @@ -38,7 +30,7 @@ class LoginView: BaseView { } override func setupView() { - addSubviews(backgroundImageView, appleLoginImageView, kakaoLoginImageView, dummyNextButton) + addSubviews(backgroundImageView, appleLoginImageView, kakaoLoginImageView) } override func setupAutoLayout() { @@ -59,12 +51,6 @@ class LoginView: BaseView { $0.horizontalEdges.equalToSuperview().inset(14) $0.height.equalTo(Screen.height(54)) } - - dummyNextButton.snp.makeConstraints { - $0.centerX.equalToSuperview() - $0.top.equalTo(kakaoLoginImageView.snp.bottom).offset(20) - $0.width.equalTo(200) - $0.height.equalTo(44) - } + } } diff --git a/KkuMulKum/Source/Onboarding/Login/ViewController/LoginViewController.swift b/KkuMulKum/Source/Onboarding/Login/ViewController/LoginViewController.swift index d8430bf5..5c80efc1 100644 --- a/KkuMulKum/Source/Onboarding/Login/ViewController/LoginViewController.swift +++ b/KkuMulKum/Source/Onboarding/Login/ViewController/LoginViewController.swift @@ -50,12 +50,6 @@ class LoginViewController: BaseViewController { ) ) loginView.kakaoLoginImageView.addGestureRecognizer(kakaoTapGesture) - - loginView.dummyNextButton.addTarget( - self, - action: #selector(dummyNextButtonTapped), - for: .touchUpInside - ) } private func bindViewModel() { @@ -65,7 +59,7 @@ class LoginViewController: BaseViewController { print("Login State: Not logged in") case .login: print("Login State: Logged in with user info: ") - owner.navigateToOnboardingScreen() + owner.navigateToMainScreen() case .needOnboarding: print("Login State: Need onboarding") owner.navigateToOnboardingScreen() @@ -79,7 +73,8 @@ class LoginViewController: BaseViewController { } } } - + + @objc private func appleLoginTapped() { loginViewModel.performAppleLogin(presentationAnchor: view.window!) }