diff --git a/.gitignore b/.gitignore index 6efbaaa..e25c661 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ Configuration/ ### Firebase ### GoogleService-Info*.plist +functions +.firebaserc +firebase.json ### macOS ### .DS_Store diff --git a/Mark-In/Resources/Info.plist b/Mark-In/Resources/Info.plist index a4b7330..9ab63ae 100644 --- a/Mark-In/Resources/Info.plist +++ b/Mark-In/Resources/Info.plist @@ -2,6 +2,10 @@ + RevokeTokenURL + $(REVOKE_TOKEN_URL) + GetRefreshTokenURL + $(GET_REFRESH_TOKEN_URL) GIDClientID $(GID_CLIENT) CFBundleURLTypes diff --git a/Mark-In/Sources/App/AuthUserManager.swift b/Mark-In/Sources/App/AuthUserManager.swift index 871285c..ce639e2 100644 --- a/Mark-In/Sources/App/AuthUserManager.swift +++ b/Mark-In/Sources/App/AuthUserManager.swift @@ -9,10 +9,13 @@ import Foundation -import FirebaseAuth +import Util -struct AuthUser { +struct AuthUser: Codable { let id: String + let name: String + let email: String + let provider: SocialSignInProvider } protocol AuthUserManager { @@ -25,22 +28,28 @@ protocol AuthUserManager { @Observable final class AuthUserManagerImpl: AuthUserManager { + + private let keychainStore: KeychainStore + var user: AuthUser? - init() { + init(keychainStore: KeychainStore) { + self.keychainStore = keychainStore self.load() } func load() { - guard let currentUser = Auth.auth().currentUser else { return } - self.user = AuthUser(id: currentUser.uid) + let user: AuthUser? = try? keychainStore.load(forKey: "authUser") + self.user = user } func save(_ user: AuthUser) { + try? keychainStore.save(user, forKey: "authUser") self.user = user } func clear() { + try? keychainStore.delete(forKey: "authUser") user = nil } } diff --git a/Mark-In/Sources/App/Config.swift b/Mark-In/Sources/App/Config.swift new file mode 100644 index 0000000..904a994 --- /dev/null +++ b/Mark-In/Sources/App/Config.swift @@ -0,0 +1,22 @@ +// +// Config.swift +// Mark-In +// +// Created by 이정동 on 5/14/25. +// + +import Foundation + +struct Config { + enum Key: String { + case getRefreshTokenURL = "GetRefreshTokenURL" + case revokeTokenURL = "RevokeTokenURL" + } + + static func value(forKey: Self.Key) -> String { + guard let value = Bundle.main.object(forInfoDictionaryKey: forKey.rawValue) as? String else { + fatalError("\(forKey.rawValue) not set") + } + return value + } +} diff --git a/Mark-In/Sources/App/DIContainer.swift b/Mark-In/Sources/App/DIContainer.swift index 801a09d..f353f61 100644 --- a/Mark-In/Sources/App/DIContainer.swift +++ b/Mark-In/Sources/App/DIContainer.swift @@ -38,8 +38,11 @@ extension DIContainer { func registerDependencies() { /// Core + let keychainStore: KeychainStore = KeychainStoreImpl() let linkMetadataProvider: LinkMetadataProvider = LinkMetadataProviderImpl() - let authUserManager: AuthUserManager = AuthUserManagerImpl() + let authUserManager: AuthUserManager = AuthUserManagerImpl( + keychainStore: keychainStore + ) register(linkMetadataProvider) register(authUserManager) @@ -66,10 +69,24 @@ extension DIContainer { let generateFolderUseCase: GenerateFolderUseCase = GenerateFolderUseCaseImpl( folderRepository: folderRepository ) + let signInUseCase: SignInUseCase = SignInUseCaseImpl( + keychainStore: keychainStore, + authUserManager: authUserManager + ) + let signOutUseCase: SignOutUseCase = SignOutUseCaseImpl( + authUserManager: authUserManager + ) + let withdrawalUseCase: WithdrawalUseCase = WithdrawalUseCaseImpl( + keychainStore: keychainStore, + authUserManager: authUserManager + ) register(fetchLinkListUseCase) register(fetchFolderListUseCase) register(generateLinkUseCase) register(generateFolderUseCase) + register(signInUseCase) + register(signOutUseCase) + register(withdrawalUseCase) } } diff --git a/Mark-In/Sources/App/KeyChainStore.swift b/Mark-In/Sources/App/KeyChainStore.swift new file mode 100644 index 0000000..156b022 --- /dev/null +++ b/Mark-In/Sources/App/KeyChainStore.swift @@ -0,0 +1,88 @@ +// +// KeyChainStore.swift +// Mark-In +// +// Created by 이정동 on 5/14/25. +// + + + + +// TODO: Core 모듈로 이동 + +import Foundation +import Security + +enum KeychainError: Error { + case encodingFailed + case decodingFailed + case unexpectedStatus(OSStatus) +} + +protocol KeychainStore { + func save(_ value: T, forKey key: String) throws + func load(forKey key: String) throws -> T? + func delete(forKey key: String) throws +} + +struct KeychainStoreImpl: KeychainStore { + + func save(_ value: T, forKey key: String) throws { + let data: Data + do { + data = try JSONEncoder().encode(value) + } catch { + throw KeychainError.encodingFailed + } + + // 기존 항목 삭제 후 저장 + try delete(forKey: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.unexpectedStatus(status) + } + } + + func load(forKey key: String) throws -> T? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + + guard let data = result as? Data else { return nil } + + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw KeychainError.decodingFailed + } + } + + func delete(forKey key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.unexpectedStatus(status) + } + } +} diff --git a/Mark-In/Sources/App/Mark_InApp.swift b/Mark-In/Sources/App/Mark_InApp.swift index 6bbcf17..c22a66f 100644 --- a/Mark-In/Sources/App/Mark_InApp.swift +++ b/Mark-In/Sources/App/Mark_InApp.swift @@ -44,10 +44,8 @@ extension Mark_InApp { #endif guard let filePath = Bundle.main.path(forResource: resource, ofType: "plist"), - let options = FirebaseOptions(contentsOfFile: filePath) - else { - print("Firebase: \(resource) not found or invalid.") - return + let options = FirebaseOptions(contentsOfFile: filePath) else { + fatalError("Firebase: \(resource) not found or invalid.") } FirebaseApp.configure(options: options) diff --git a/Mark-In/Sources/Domain/UseCases/Implements/SignInUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/SignInUseCaseImpl.swift new file mode 100644 index 0000000..a83e8e3 --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Implements/SignInUseCaseImpl.swift @@ -0,0 +1,94 @@ +// +// SignInUseCaseImpl.swift +// Mark-In +// +// Created by 이정동 on 5/15/25. +// + +import Foundation + +import FirebaseAuth + +import Util + +struct SignInUseCaseImpl: SignInUseCase { + + private let keychainStore: KeychainStore + private let authUserManager: AuthUserManager + + init( + keychainStore: KeychainStore, + authUserManager: AuthUserManager + ) { + self.keychainStore = keychainStore + self.authUserManager = authUserManager + } + + func signIn(using info: AppleSignInInfo) async throws { + /// 애플 로그인 정보에 필요한 정보들이 누락되는 경우 + guard let nonce = info.nonce, + let appleIDToken = info.idCredential.identityToken, + let idTokenString = String(data: appleIDToken, encoding: .utf8), + let authorizationCode = info.idCredential.authorizationCode, + let codeString = String(data: authorizationCode, encoding: .utf8) else { + throw SignInError.missingData + } + + // TODO: 아래 코드들을 Core(Auth) 모듈에서 처리하도록 리팩토링 + /// 애플 서버에 Refresh Token 요청 + let urlString = "https://\(Config.value(forKey: .getRefreshTokenURL))/getRefreshToken?code=\(codeString)" + let url = URL(string: urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)! + + let (data, _) = try await URLSession.shared.data(from: url) + let refreshToken = String(data: data, encoding: .utf8) ?? "" + + /// Refresh Token 저장 + try keychainStore.save(refreshToken, forKey: "refreshToken") + + /// Firebase 인증 요청을 위한 AuthCredential 생성 + let credential = OAuthProvider.appleCredential( + withIDToken: idTokenString, + rawNonce: nonce, + fullName: info.idCredential.fullName + ) + + /// Firebase 인증 요청 + let response = try await Auth.auth().signIn(with: credential) + + /// AuthUserManager에 유저 데이터 저장 + let authUser = AuthUser( + id: response.user.uid, + name: response.user.displayName ?? "-", + email: response.user.email ?? "-", + provider: .apple + ) + authUserManager.save(authUser) + } + + func signIn(using info: GoogleSignInInfo) async throws { + /// 로그인에 필요한 정보(IDToken)가 누락된 경우 + guard let idToken = info.user.idToken?.tokenString else { + throw SignInError.missingData + } + + // TODO: 아래 코드들을 Core(Auth) 모듈에서 처리하도록 리팩토링 + /// Firebase 인증 요청을 위한 AuthCredential 생성 + let credential = GoogleAuthProvider.credential( + withIDToken: idToken, + accessToken: info.user.accessToken.tokenString + ) + + /// Firebase 인증 요청 + let response = try await Auth.auth().signIn(with: credential) + + /// AuthUserManager에 유저 데이터 저장 + let authUser = AuthUser( + id: response.user.uid, + name: response.user.displayName ?? "-", + email: response.user.email ?? "-", + provider: .google + ) + authUserManager.save(authUser) + } +} + diff --git a/Mark-In/Sources/Domain/UseCases/Implements/SignOutUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/SignOutUseCaseImpl.swift new file mode 100644 index 0000000..ef37622 --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Implements/SignOutUseCaseImpl.swift @@ -0,0 +1,32 @@ +// +// SignOutUseCaseImpl.swift +// Mark-In +// +// Created by 이정동 on 5/15/25. +// + +import Foundation + +import FirebaseAuth +import GoogleSignIn + +struct SignOutUseCaseImpl: SignOutUseCase { + + private let authUserManager: AuthUserManager + + init(authUserManager: AuthUserManager) { + self.authUserManager = authUserManager + } + + func execute() { + // TODO: Core(Auth) 모듈에서 처리하도록 리팩토링 + try? Auth.auth().signOut() + + /// 구글 사용자인 경우 + if authUserManager.user?.provider == .google { + GIDSignIn.sharedInstance.signOut() + } + + authUserManager.clear() + } +} diff --git a/Mark-In/Sources/Domain/UseCases/Implements/WithdrawalUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/WithdrawalUseCaseImpl.swift new file mode 100644 index 0000000..7213c68 --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Implements/WithdrawalUseCaseImpl.swift @@ -0,0 +1,54 @@ +// +// WithdrawalUseCaseImpl.swift +// Mark-In +// +// Created by 이정동 on 5/15/25. +// + +import Foundation + +import FirebaseAuth +import GoogleSignIn + +struct WithdrawalUseCaseImpl: WithdrawalUseCase { + + private let keychainStore: KeychainStore + private let authUserManager: AuthUserManager + + init(keychainStore: KeychainStore, authUserManager: AuthUserManager) { + self.keychainStore = keychainStore + self.authUserManager = authUserManager + } + + func execute() async throws { + do { + try await Auth.auth().currentUser?.delete() + } catch { + throw WithdrawalError.credentialTooOld + } + + guard let provider = authUserManager.user?.provider else { return } + + switch provider { + case .apple: + let token: String? = try? keychainStore.load(forKey: "refreshToken") + guard let token else { return } + + let url = URL(string: "https://\(Config.value(forKey: .revokeTokenURL))/revokeToken?refresh_token=\(token)" + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)! + + _ = try await URLSession.shared.data(from: url) + + try keychainStore.delete(forKey: "refreshToken") + + case .google: + try? await GIDSignIn.sharedInstance.disconnect() + GIDSignIn.sharedInstance.signOut() + + @unknown default: + fatalError("") + } + + authUserManager.clear() + } +} diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/SignInUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/SignInUseCase.swift new file mode 100644 index 0000000..5ea9db5 --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Interfaces/SignInUseCase.swift @@ -0,0 +1,32 @@ +// +// SignInUseCase.swift +// Mark-In +// +// Created by 이정동 on 5/15/25. +// + +import AuthenticationServices +import Foundation + +import GoogleSignIn + +import Util + +enum SignInError: Error { + case missingData + case invalid +} + +struct AppleSignInInfo { + let nonce: String? + let idCredential: ASAuthorizationAppleIDCredential +} + +struct GoogleSignInInfo { + let user: GIDGoogleUser +} + +protocol SignInUseCase { + func signIn(using info: AppleSignInInfo) async throws + func signIn(using info: GoogleSignInInfo) async throws +} diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/SignOutUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/SignOutUseCase.swift new file mode 100644 index 0000000..59ca690 --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Interfaces/SignOutUseCase.swift @@ -0,0 +1,12 @@ +// +// SignOutUseCase.swift +// Mark-In +// +// Created by 이정동 on 5/15/25. +// + +import Foundation + +protocol SignOutUseCase { + func execute() +} diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/WithdrawalUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/WithdrawalUseCase.swift new file mode 100644 index 0000000..52ad45c --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Interfaces/WithdrawalUseCase.swift @@ -0,0 +1,16 @@ +// +// WithdrawalUseCase.swift +// Mark-In +// +// Created by 이정동 on 5/15/25. +// + +import Foundation + +enum WithdrawalError: Error { + case credentialTooOld +} + +protocol WithdrawalUseCase { + func execute() async throws +} diff --git a/Mark-In/Sources/Feature/Login/LoginView.swift b/Mark-In/Sources/Feature/Login/LoginView.swift index 5d39f4e..7c6b5e7 100644 --- a/Mark-In/Sources/Feature/Login/LoginView.swift +++ b/Mark-In/Sources/Feature/Login/LoginView.swift @@ -109,7 +109,6 @@ private struct SignInButtonList: View { } SignInButton(provider: .google) { - // TODO: 구글 로그인 로직 loginViewModel.send(.googleLoginButtonTapped) } } diff --git a/Mark-In/Sources/Feature/Login/LoginViewModel.swift b/Mark-In/Sources/Feature/Login/LoginViewModel.swift index 9a2c7d4..d913d4e 100644 --- a/Mark-In/Sources/Feature/Login/LoginViewModel.swift +++ b/Mark-In/Sources/Feature/Login/LoginViewModel.swift @@ -24,19 +24,17 @@ final class LoginViewModel: Reducer { case appleLoginButtonTapped(AuthorizationController) case googleLoginButtonTapped - case signInError(SignInError) - - case firebaseAuthRequest(AuthCredential) - case firebaseAuthResponse(Result) + case signInError case empty } + private let signInUseCase: SignInUseCase + private(set) var state: State = .init() - private let authUserManager: AuthUserManager init() { - self.authUserManager = DIContainer.shared.resolve() + self.signInUseCase = DIContainer.shared.resolve() } func send(_ action: Action) { @@ -59,25 +57,18 @@ final class LoginViewModel: Reducer { guard let result = try? await authController.performRequest(request), case let .appleID(idCredential) = result else { return .empty } - /// 애플 로그인 정보에 필요한 정보들이 누락되는 경우 - guard let nonce, - let appleIDToken = idCredential.identityToken, - let idTokenString = String(data: appleIDToken, encoding: .utf8) else { - return .signInError(.missingData) - } - - /// Firebase 인증 요청을 위한 AuthCredential 생성 - let credential = OAuthProvider.appleCredential( - withIDToken: idTokenString, - rawNonce: nonce, - fullName: idCredential.fullName - ) + let appleSignInInfo = AppleSignInInfo(nonce: nonce, idCredential: idCredential) - return .firebaseAuthRequest(credential) + do { + try await self.signInUseCase.signIn(using: appleSignInInfo) + return .empty + } catch { + return .signInError + } } case .googleLoginButtonTapped: - + guard let windowScene = NSApplication.shared.windows.first else { return .none } @@ -87,50 +78,17 @@ final class LoginViewModel: Reducer { /// 구글 로그인 요청 let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: windowScene) - /// 로그인에 필요한 정보(IDToken)가 누락된 경우 - guard let idToken = result.user.idToken?.tokenString else { - return .signInError(.missingData) - } + let googleSignInInfo = GoogleSignInInfo(user: result.user) + try await self.signInUseCase.signIn(using: googleSignInInfo) - /// Firebase 인증 요청을 위한 AuthCredential 생성 - let credential = GoogleAuthProvider.credential( - withIDToken: idToken, - accessToken: result.user.accessToken.tokenString - ) - - return .firebaseAuthRequest(credential) + return .empty } catch { - return .signInError(.invalid) + return .signInError } } // TODO: 에러 처리 필요 - case .signInError(_): - return .none - - // TODO: 구글 로그인까지 구현 후 디테일 수정 - case .firebaseAuthRequest(let credential): - return .run { - do { - /// Firebase 인증 요청 - let response = try await Auth.auth().signIn(with: credential) - - return .firebaseAuthResponse(.success(response.user.uid)) - } catch { - return .firebaseAuthResponse(.failure(error)) - } - } - - case .firebaseAuthResponse(let result): - switch result { - case .success(let id): - let user = AuthUser(id: id) - authUserManager.save(user) - case .failure(let error): - // TODO: 에러 처리 필요 - let _ = error as? AuthErrorCode - break - } + case .signInError: return .none case .empty: @@ -150,10 +108,3 @@ final class LoginViewModel: Reducer { } } } - -extension LoginViewModel { - enum SignInError: Error { - case missingData - case invalid - } -} diff --git a/Mark-In/Sources/Feature/MyPage/MyPageViewModel.swift b/Mark-In/Sources/Feature/MyPage/MyPageViewModel.swift index 1611bb9..5d2fd0f 100644 --- a/Mark-In/Sources/Feature/MyPage/MyPageViewModel.swift +++ b/Mark-In/Sources/Feature/MyPage/MyPageViewModel.swift @@ -20,19 +20,19 @@ final class MyPageViewModel: Reducer { case logoutButtonTapped case withdrawalButtonTapped - case didSuccessLogout - case didFailLogout - - case didSuccessWithdrawal case didFailWithdrawal + + case empty } - private let authUserManager: AuthUserManager + private let signOutUseCase: SignOutUseCase + private let withdrawalUseCase: WithdrawalUseCase private(set) var state: State = .init() init() { - self.authUserManager = DIContainer.shared.resolve() + self.signOutUseCase = DIContainer.shared.resolve() + self.withdrawalUseCase = DIContainer.shared.resolve() } func send(_ action: Action) { @@ -43,54 +43,26 @@ final class MyPageViewModel: Reducer { func reduce(state: inout State, action: Action) -> Effect { switch action { case .logoutButtonTapped: - return .run { - // TODO: 이후 Auth 모듈로 분리하면서 코드 리팩토링 예정 - do { - try Auth.auth().signOut() - - if GIDSignIn.sharedInstance.currentUser != nil { - GIDSignIn.sharedInstance.signOut() - } - return .didSuccessLogout - } catch { - return .didFailLogout - } - } + signOutUseCase.execute() + return .none case .withdrawalButtonTapped: return .run { // TODO: 이후 Auth 모듈로 분리하면서 코드 리팩토링 예정 do { - // TODO: 재인증 과정 필요 - // try await Auth.auth().currentUser?.reauthenticate(with: credential) - try await Auth.auth().currentUser?.delete() - - if GIDSignIn.sharedInstance.currentUser != nil { - try await GIDSignIn.sharedInstance.disconnect() - GIDSignIn.sharedInstance.signOut() - } - - return .didSuccessWithdrawal + try await self.withdrawalUseCase.execute() + return .empty } catch { return .didFailWithdrawal } } - case .didSuccessLogout: - authUserManager.clear() - return .none - - // TODO: 로그아웃 실패에 대한 처리 필요 - case .didFailLogout: - return .none - - case .didSuccessWithdrawal: - authUserManager.clear() - return .none - // TODO: 회원탈퇴 실패에 대한 처리 필요 case .didFailWithdrawal: return .none + + case .empty: + return .none } } diff --git a/Shared/Util/Sources/OAuthProvider.swift b/Shared/Util/Sources/OAuthProvider.swift index c5cb681..5445bad 100644 --- a/Shared/Util/Sources/OAuthProvider.swift +++ b/Shared/Util/Sources/OAuthProvider.swift @@ -7,7 +7,7 @@ import Foundation -public enum SocialSignInProvider { +public enum SocialSignInProvider: Codable { case apple case google }