diff --git a/Mark-In.xcodeproj/project.pbxproj b/Mark-In.xcodeproj/project.pbxproj index 012dd18..0ecd3a0 100644 --- a/Mark-In.xcodeproj/project.pbxproj +++ b/Mark-In.xcodeproj/project.pbxproj @@ -75,9 +75,22 @@ 57BFD8C32DA4E19600648AD4 /* Mark-In.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mark-In.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 57B7E0242DC3D44100D9225A /* Exceptions for "Mark-In" folder in "Mark-In" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resources/Info.plist, + ); + target = 57BFD8C22DA4E19600648AD4 /* Mark-In */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 57BFD8C52DA4E19600648AD4 /* Mark-In */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 57B7E0242DC3D44100D9225A /* Exceptions for "Mark-In" folder in "Mark-In" target */, + ); path = "Mark-In"; sourceTree = ""; }; @@ -431,6 +444,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Mark-In/Resources/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "[Dev] Mark-In"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; @@ -471,7 +485,8 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "Mark-In"; + INFOPLIST_FILE = "Mark-In/Resources/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "[Dev] Mark-In"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; diff --git a/Mark-In/Resources/Info.plist b/Mark-In/Resources/Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Mark-In/Resources/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Mark-In/Resources/Mark_In.entitlements b/Mark-In/Resources/Mark_In.entitlements index 625af03..94fbab1 100644 --- a/Mark-In/Resources/Mark_In.entitlements +++ b/Mark-In/Resources/Mark_In.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.applesignin + + Default + com.apple.security.app-sandbox com.apple.security.files.user-selected.read-only diff --git a/Mark-In/Sources/Feature/Login/LoginView.swift b/Mark-In/Sources/Feature/Login/LoginView.swift index b44dcd9..c1f14ec 100644 --- a/Mark-In/Sources/Feature/Login/LoginView.swift +++ b/Mark-In/Sources/Feature/Login/LoginView.swift @@ -6,11 +6,14 @@ // import SwiftUI +import AuthenticationServices import DesignSystem import Util struct LoginView: View { + @State private var loginViewModel = LoginViewModel() + var body: some View { ZStack { LinearGradient( @@ -23,10 +26,9 @@ struct LoginView: View { VStack(spacing: 28) { headerView - BodyView() + BodyView(loginViewModel: loginViewModel) } } - } @ViewBuilder @@ -44,6 +46,11 @@ struct LoginView: View { } private struct BodyView: View { + private let loginViewModel: LoginViewModel + + init(loginViewModel: LoginViewModel) { + self.loginViewModel = loginViewModel + } var body: some View { VStack(spacing: 2) { @@ -52,7 +59,7 @@ private struct BodyView: View { divider - SignInButtonList() + SignInButtonList(loginViewModel: loginViewModel) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -88,12 +95,17 @@ private struct BodyView: View { } private struct SignInButtonList: View { - // TODO: LoginViewModel 전달 받도록 + @Environment(\.authorizationController) private var authorizationController + private let loginViewModel: LoginViewModel + + init(loginViewModel: LoginViewModel) { + self.loginViewModel = loginViewModel + } var body: some View { VStack(spacing: 12) { SignInButton(provider: .apple) { - // TODO: 애플 로그인 로직 + loginViewModel.send(.appleLoginButtonTapped(authorizationController)) } SignInButton(provider: .google) { diff --git a/Mark-In/Sources/Feature/Login/LoginViewModel.swift b/Mark-In/Sources/Feature/Login/LoginViewModel.swift new file mode 100644 index 0000000..bb54c39 --- /dev/null +++ b/Mark-In/Sources/Feature/Login/LoginViewModel.swift @@ -0,0 +1,122 @@ +// +// LoginViewModel.swift +// Mark-In +// +// Created by 이정동 on 5/1/25. +// + +import Foundation +import AuthenticationServices +import SwiftUI + +import FirebaseAuth + +import Util + +@Observable +final class LoginViewModel: Reducer { + struct State { + var isSignInSuccess: Bool = false + } + + enum Action { + case appleLoginButtonTapped(AuthorizationController) + + case signInError(SignInError) + + case firebaseAuthRequest(AuthCredential) + case firebaseAuthResponse(Result) + + case empty + } + + private(set) var state: State = .init() + + func send(_ action: Action) { + let effect = reduce(state: &state, action: action) + handleEffect(effect) + } + + func reduce(state: inout State, action: Action) -> Effect { + switch action { + case .appleLoginButtonTapped(let authController): + /// 1. 애플 로그인 요청을 위한 객체 생성 + let requestProvider = SignInWithAppleRequestProvider() + let request = requestProvider.makeRequest() + + /// 2. 애플 로그인 인증 요청 + return .run { [nonce = requestProvider.currentNonce] in + + /// 중간에 로그인을 취소하거나, 애플 로그인 인증 방식이 아닌 경우는 빈 액션 반환 + /// (에러 상황은 아니고, 어떠한 액션을 던질 필요가 없음) + 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(.invalid) + } + + /// Firebase 인증 요청을 위한 AuthCredential 생성 + let credential = OAuthProvider.appleCredential( + withIDToken: idTokenString, + rawNonce: nonce, + fullName: idCredential.fullName + ) + + return .firebaseAuthRequest(credential) + } + + // TODO: 에러 처리 필요 + case .signInError(_): + return .none + + // TODO: 현재는 러프하고 구현된 상태. 구글 로그인까지 구현 후 디테일 수정 + case .firebaseAuthRequest(let credential): + return .run { + do { + /// Firebase 인증 요청 + let _ = try await Auth.auth().signIn(with: credential) + + return .firebaseAuthResponse(.success(())) + } catch { + return .firebaseAuthResponse(.failure(error)) + } + } + + case .firebaseAuthResponse(let result): + switch result { + case .success(_): + state.isSignInSuccess = true + case .failure(let error): + // TODO: 에러 처리 필요 + let _ = error as? AuthErrorCode + break + } + return .none + + case .empty: + return .none + } + } + + private func handleEffect(_ effect: Effect) { + switch effect { + case .none: + break + case .run(let action): + Task { + let newAction = await action() + send(newAction) + } + } + } +} + +extension LoginViewModel { + enum SignInError: Error { + case invalid + } +} diff --git a/Mark-In/Sources/Feature/Login/SignInWithAppleRequestProvider.swift b/Mark-In/Sources/Feature/Login/SignInWithAppleRequestProvider.swift new file mode 100644 index 0000000..13d0bc6 --- /dev/null +++ b/Mark-In/Sources/Feature/Login/SignInWithAppleRequestProvider.swift @@ -0,0 +1,54 @@ +// +// SignInWithAppleRequestProvider.swift +// Mark-In +// +// Created by 이정동 on 5/2/25. +// + +import Foundation +import CryptoKit +import AuthenticationServices + +final class SignInWithAppleRequestProvider { + + private(set) var currentNonce: String? + + func makeRequest() -> ASAuthorizationAppleIDRequest { + let nonce = self.randomNonceString() + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + request.requestedScopes = [.fullName, .email] + request.nonce = self.sha256(nonce) + self.currentNonce = nonce + return request + } + + private func randomNonceString(length: Int = 32) -> String { + precondition(length > 0) + var randomBytes = [UInt8](repeating: 0, count: length) + let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes) + if errorCode != errSecSuccess { + fatalError( + "Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)" + ) + } + + let charset: [Character] = + Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") + + let nonce = randomBytes.map { byte in + // Pick a random character from the set, wrapping around if needed. + charset[Int(byte) % charset.count] + } + + return String(nonce) + } + + private func sha256(_ input: String) -> String { + let inputData = Data(input.utf8) + let hashedData = SHA256.hash(data: inputData) + let hashString = hashedData.compactMap { String(format: "%02x", $0) }.joined() + + return hashString + } +}