diff --git a/DOKI.xcodeproj/project.pbxproj b/DOKI.xcodeproj/project.pbxproj index 8b590991..2ad429f5 100644 --- a/DOKI.xcodeproj/project.pbxproj +++ b/DOKI.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 766B2E492E254BD600F3A668 /* Moya in Frameworks */ = {isa = PBXBuildFile; productRef = 766B2E482E254BD600F3A668 /* Moya */; }; + 76C154E62EE46DDF00A8ED40 /* NMapsMap in Frameworks */ = {isa = PBXBuildFile; productRef = 76C154E52EE46DDF00A8ED40 /* NMapsMap */; }; 76ECCF2D2E05C4820056CAF7 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 76ECCF2C2E05C4820056CAF7 /* Kingfisher */; }; 76ECCF302E05C4CC0056CAF7 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 76ECCF2F2E05C4CC0056CAF7 /* Lottie */; }; /* End PBXBuildFile section */ @@ -43,6 +44,7 @@ buildActionMask = 2147483647; files = ( 76ECCF2D2E05C4820056CAF7 /* Kingfisher in Frameworks */, + 76C154E62EE46DDF00A8ED40 /* NMapsMap in Frameworks */, 76ECCF302E05C4CC0056CAF7 /* Lottie in Frameworks */, 766B2E492E254BD600F3A668 /* Moya in Frameworks */, ); @@ -90,6 +92,7 @@ 76ECCF2C2E05C4820056CAF7 /* Kingfisher */, 76ECCF2F2E05C4CC0056CAF7 /* Lottie */, 766B2E482E254BD600F3A668 /* Moya */, + 76C154E52EE46DDF00A8ED40 /* NMapsMap */, ); productName = DoggyWalker; productReference = 76ECCDED2E05AFCC0056CAF7 /* DOKI.app */; @@ -123,6 +126,7 @@ 76ECCF2B2E05C4820056CAF7 /* XCRemoteSwiftPackageReference "Kingfisher" */, 76ECCF2E2E05C4CC0056CAF7 /* XCRemoteSwiftPackageReference "lottie-ios" */, 766B2E472E254BD600F3A668 /* XCRemoteSwiftPackageReference "Moya" */, + 76C154E42EE46DDF00A8ED40 /* XCRemoteSwiftPackageReference "SPM-NMapsMap" */, ); preferredProjectObjectVersion = 77; productRefGroup = 76ECCDEE2E05AFCC0056CAF7 /* Products */; @@ -162,7 +166,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - BASE_URL = "www.pawkey.o-r.kr/api/v1/"; + BASE_URL = ""; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -228,7 +232,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - BASE_URL = "www.pawkey.o-r.kr/api/v1/"; + BASE_URL = ""; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -282,12 +286,15 @@ }; 76ECCDF92E05AFCC0056CAF7 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 76ECCDEF2E05AFCC0056CAF7 /* DOKI */; + baseConfigurationReferenceRelativePath = Config.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = DOKI/DOKI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = Z9PQC69UPK; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = DOKI/Info.plist; @@ -319,9 +326,10 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = DOKI/DOKI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = Z9PQC69UPK; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = DOKI/Info.plist; @@ -380,6 +388,14 @@ minimumVersion = 15.0.3; }; }; + 76C154E42EE46DDF00A8ED40 /* XCRemoteSwiftPackageReference "SPM-NMapsMap" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/navermaps/SPM-NMapsMap"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.23.0; + }; + }; 76ECCF2B2E05C4820056CAF7 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/onevcat/Kingfisher"; @@ -404,6 +420,11 @@ package = 766B2E472E254BD600F3A668 /* XCRemoteSwiftPackageReference "Moya" */; productName = Moya; }; + 76C154E52EE46DDF00A8ED40 /* NMapsMap */ = { + isa = XCSwiftPackageProductDependency; + package = 76C154E42EE46DDF00A8ED40 /* XCRemoteSwiftPackageReference "SPM-NMapsMap" */; + productName = NMapsMap; + }; 76ECCF2C2E05C4820056CAF7 /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; package = 76ECCF2B2E05C4820056CAF7 /* XCRemoteSwiftPackageReference "Kingfisher" */; diff --git a/DOKI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DOKI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7a434681..deb29b09 100644 --- a/DOKI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DOKI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fc639fd733ced6b35537b4c9370e6e9a4b9aa79783e3568a6f68abcb40d4a8fa", + "originHash" : "5e223e348c5e7fc2156f51eba94fe4e748e00223d98a5ef154d32f929a170c17", "pins" : [ { "identity" : "alamofire", @@ -54,6 +54,24 @@ "revision" : "5dd1907d64f0d36f158f61a466bab75067224893", "version" : "6.9.0" } + }, + { + "identity" : "spm-nmapsgeometry", + "kind" : "remoteSourceControl", + "location" : "https://github.com/navermaps/SPM-NMapsGeometry.git", + "state" : { + "revision" : "436d5e2e684f557faf5ef5862fd6633a42d7af11", + "version" : "1.0.2" + } + }, + { + "identity" : "spm-nmapsmap", + "kind" : "remoteSourceControl", + "location" : "https://github.com/navermaps/SPM-NMapsMap", + "state" : { + "revision" : "fb4eef37db9904c0a0dcdf7a828c892e13782cfa", + "version" : "3.23.0" + } } ], "version" : 3 diff --git a/DOKI/Application/DOKIApp.swift b/DOKI/Application/DOKIApp.swift index 9839d122..c28d9b33 100644 --- a/DOKI/Application/DOKIApp.swift +++ b/DOKI/Application/DOKIApp.swift @@ -10,7 +10,7 @@ import SwiftUI @main struct DOKIApp: App { @StateObject var appDIContainer = AppDIContainer() - @StateObject var authManager = AuthManager() + @StateObject var authManager = AuthManager.shared var body: some Scene { WindowGroup { diff --git a/DOKI/DOKI.entitlements b/DOKI/DOKI.entitlements new file mode 100644 index 00000000..a812db50 --- /dev/null +++ b/DOKI/DOKI.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/DOKI/Global/Manager/AuthManager.swift b/DOKI/Global/Manager/AuthManager.swift index aabd9b2f..8988d5c4 100644 --- a/DOKI/Global/Manager/AuthManager.swift +++ b/DOKI/Global/Manager/AuthManager.swift @@ -7,6 +7,8 @@ import SwiftUI +import Moya + enum AuthState: String, CaseIterable { case loggedIn case loggedOut @@ -14,13 +16,64 @@ enum AuthState: String, CaseIterable { } class AuthManager: ObservableObject { + static let shared = AuthManager() + @Published var authStatus: AuthState = .loading + private(set) var accessToken: String? + private(set) var refreshToken: String? + + private let provider = MoyaProvider(plugins: [NetworkLoggerPlugin()]) + + private init() {} + func checkLogin() { - authStatus = .loggedIn + do { + self.accessToken = try KeychainManager.read(.accessToken) + self.refreshToken = try KeychainManager.read(.refreshToken) + authStatus = .loggedIn + } catch { + authStatus = .loggedOut + print(error.localizedDescription) + } } - func login() { - authStatus = .loggedIn + /// AppleLogin API + func loginWithApple(_ idToken: String, deviceId: String) async { + do { + let appleLoginReqDto = AppleLoginRequestDTO(idToken: idToken, deviceId: deviceId) + let response: AppleLoginResponseDTO = try await provider.async.request(.appleLogin(appleLoginReqDto: appleLoginReqDto)) + try KeychainManager.create(.accessToken, response.accessToken) + try KeychainManager.create(.refreshToken, response.refreshToken) + self.accessToken = response.accessToken + self.refreshToken = response.refreshToken + + authStatus = .loggedIn + } catch { + print(error.localizedDescription) + } + } + + func logout() { + do { + try KeychainManager.delete(.accessToken) + try KeychainManager.delete(.refreshToken) + authStatus = .loggedOut + } catch { + print(error.localizedDescription) + } + } + + func reissueToken(accessToken: String, refreshToken: String) { + do { + try KeychainManager.create(.accessToken, accessToken) + try KeychainManager.create(.refreshToken, refreshToken) + self.accessToken = accessToken + self.refreshToken = refreshToken + } catch { + print(error.localizedDescription) + } } } + + diff --git a/DOKI/Global/Manager/KeychainManager.swift b/DOKI/Global/Manager/KeychainManager.swift new file mode 100644 index 00000000..b0e9442d --- /dev/null +++ b/DOKI/Global/Manager/KeychainManager.swift @@ -0,0 +1,81 @@ +// +// KeychainManager.swift +// DOKI +// +// Created by a on 12/7/25. +// + +import Foundation + +enum KeychainError: Error { + case noPassword + case unhandledError(status: OSStatus) + case unexpectedPasswordData + + var message: String { + switch self { + case .noPassword: return "No password available." + case .unexpectedPasswordData: return "Expected data, but found none." + case .unhandledError(let status): return "Unhandled error with status: \(status)" + } + } +} + +enum KeychainName: String { + case accessToken + case refreshToken +} + +struct KeychainManager { + + /// Keychain 저장소에서 key에 해당하는 값을 추가 + static func create(_ key: KeychainName, _ value: T) throws { + do { + let valueData = try JSONEncoder().encode(value) + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key.rawValue, + kSecValueData: valueData + ] + SecItemDelete(query) + + let status = SecItemAdd(query, nil) + guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } + } catch { + throw KeychainError.unexpectedPasswordData + } + } + + /// Keychain 저장소에서 key에 해당하는 값을 검색 + @discardableResult + static func read(_ key: KeychainName) throws -> String? { + let query: NSDictionary = [kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key.rawValue, + kSecMatchLimit: kSecMatchLimitOne, + kSecReturnData: true] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status != errSecItemNotFound else { throw KeychainError.noPassword } + if status == errSecSuccess { + if let retrievedItem = item as? Data { + let returnValue = String(data: retrievedItem, encoding: String.Encoding.utf8) + return returnValue + } else { + return nil + } + } else { + throw KeychainError.unexpectedPasswordData + } + } + + /// key에 해당하는 값을 삭제 + static func delete(_ key: KeychainName) throws { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key.rawValue + ] + let status = SecItemDelete(query) + guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } + } +} + diff --git a/DOKI/Network/Base/AuthInterceptor.swift b/DOKI/Network/Base/AuthInterceptor.swift new file mode 100644 index 00000000..5f0f5026 --- /dev/null +++ b/DOKI/Network/Base/AuthInterceptor.swift @@ -0,0 +1,88 @@ +// +// AuthInterceptor.swift +// DOKI +// +// Created by a on 12/9/25. +// + +import Foundation + +import Moya +import Alamofire + +final class AuthInterceptor: RequestInterceptor { + static let shared = AuthInterceptor() + + private init() {} + + // 네트워크 요청하기전 헤더에 accessToken 추가 + func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { + var request = urlRequest + + if request.url?.absoluteString.contains("auth/refresh") == true { + completion(.success(request)) + return + } + if let accessToken = AuthManager.shared.accessToken { + request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } + + completion(.success(request)) + } + + func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) { + // 401인 경우가 아니라면 종료 + guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401 else { + completion(.doNotRetryWithError(error)) + return + } + + // refreshToken 가져오기 없다면 종료 + guard let refreshToken = AuthManager.shared.refreshToken?.replacingOccurrences(of: "\"", with: "") else { + completion(.doNotRetry) + AuthManager.shared.logout() + return + } + + // 토큰 재발급 API 호출 & 토큰 교체 + var refreshRequest = URLRequest(url: URL(string: Config.baseURL + "auth/refresh")!) + refreshRequest.httpMethod = "POST" + refreshRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") + + let requestBody = try? JSONSerialization.data(withJSONObject: ["refreshToken": refreshToken, "deviceId": "doki-service"]) + refreshRequest.httpBody = requestBody + let defaultSession = URLSession(configuration: .default) + + defaultSession.dataTask(with: refreshRequest) { (data: Data?, response: URLResponse?, error: Error?) in + // 에러 발생시 재요청x + guard error == nil else { + completion(.doNotRetry) + AuthManager.shared.logout() + return + } + + guard let data, let response = response as? HTTPURLResponse, (200..<300) ~= response.statusCode else { + completion(.doNotRetry) + AuthManager.shared.logout() + return + } + + // 토큰 재발급 요청 성공 + do { + let response = try JSONDecoder().decode(AppleLoginResponseDTO.self, from: data) + // 토큰 재발급 + AuthManager.shared.reissueToken( + accessToken: response.accessToken, + refreshToken: response.refreshToken + ) + // 재요청 + print("토큰 재발급 성공 - 재요청") + completion(.retry) + } catch { + print("토큰 재발급 실패 - 로그아웃") + completion(.doNotRetryWithError(error)) + AuthManager.shared.logout() + } + }.resume() + } +} diff --git a/DOKI/Network/Base/BaseTargetType.swift b/DOKI/Network/Base/BaseTargetType.swift index 1525152d..6537b90d 100644 --- a/DOKI/Network/Base/BaseTargetType.swift +++ b/DOKI/Network/Base/BaseTargetType.swift @@ -10,8 +10,7 @@ import Foundation import Moya enum HeaderType { - case noneHeader - case userHeader(userId: Int) + case defaultHeader } protocol BaseTargetType: TargetType { @@ -28,13 +27,8 @@ extension BaseTargetType { var headers: [String: String]? { switch headerType { - case .noneHeader: - return nil - case .userHeader(let userId): - return [ - "Content-Type": "application/json", - "X-USER-ID": String(userId), - ] + case .defaultHeader: + return ["Content-Type": "application/json"] } } } diff --git a/DOKI/Network/Login/DTOs/AppleLoginRequestDTO.swift b/DOKI/Network/Login/DTOs/AppleLoginRequestDTO.swift new file mode 100644 index 00000000..cb95a719 --- /dev/null +++ b/DOKI/Network/Login/DTOs/AppleLoginRequestDTO.swift @@ -0,0 +1,13 @@ +// +// AppleLoginRequestDTO.swift +// DOKI +// +// Created by a on 12/7/25. +// + +import Foundation + +struct AppleLoginRequestDTO: Encodable { + let idToken: String + let deviceId: String +} diff --git a/DOKI/Network/Login/DTOs/AppleLoginResponseDTO.swift b/DOKI/Network/Login/DTOs/AppleLoginResponseDTO.swift new file mode 100644 index 00000000..5019cd5d --- /dev/null +++ b/DOKI/Network/Login/DTOs/AppleLoginResponseDTO.swift @@ -0,0 +1,13 @@ +// +// AppleLoginResponseDTO.swift +// DOKI +// +// Created by a on 12/7/25. +// + +import Foundation + +struct AppleLoginResponseDTO: Codable { + let accessToken: String + let refreshToken: String +} diff --git a/DOKI/Network/Login/LoginAPI.swift b/DOKI/Network/Login/LoginAPI.swift new file mode 100644 index 00000000..f1f84381 --- /dev/null +++ b/DOKI/Network/Login/LoginAPI.swift @@ -0,0 +1,44 @@ +// +// LoginAPI.swift +// DOKI +// +// Created by a on 12/7/25. +// + +import Foundation + +import Moya + +enum LoginAPI { + case appleLogin(appleLoginReqDto: AppleLoginRequestDTO) +} + +extension LoginAPI: BaseTargetType { + var headerType: HeaderType { + switch self { + case .appleLogin: + return .defaultHeader + } + } + + var path: String { + switch self { + case .appleLogin: + return "auth/apple/login" + } + } + + var method: Moya.Method { + switch self { + case .appleLogin: + return .post + } + } + + var task: Task { + switch self { + case let .appleLogin(appleLoginReqDto): + return .requestJSONEncodable(appleLoginReqDto) + } + } +} diff --git a/DOKI/Network/Region/RegionAPI.swift b/DOKI/Network/Region/RegionAPI.swift new file mode 100644 index 00000000..f97c33ab --- /dev/null +++ b/DOKI/Network/Region/RegionAPI.swift @@ -0,0 +1,48 @@ +// +// RegionAPI.swift +// DOKI +// +// Created by a on 12/9/25. +// + +import Foundation + +import Moya + +enum RegionAPI { + case getRegions +} + +extension RegionAPI: BaseTargetType { + var validationType: ValidationType { + .successCodes + } + + var headerType: HeaderType { + switch self { + case .getRegions: + return .defaultHeader + } + } + + var path: String { + switch self { + case .getRegions: + return "regions" + } + } + + var method: Moya.Method { + switch self { + case .getRegions: + return .get + } + } + + var task: Task { + switch self { + case .getRegions: + return .requestPlain + } + } +} diff --git a/DOKI/Presentation/Home/View/HomeView.swift b/DOKI/Presentation/Home/View/HomeView.swift index 1c80e8d0..82bb024e 100644 --- a/DOKI/Presentation/Home/View/HomeView.swift +++ b/DOKI/Presentation/Home/View/HomeView.swift @@ -7,10 +7,26 @@ import SwiftUI +import Moya + struct HomeView: View { @StateObject var viewModel: HomeViewModel + private let provider = MoyaProvider(session: .init(interceptor: AuthInterceptor.shared), plugins: [NetworkLoggerPlugin()]) + @State var errorMessage = "" + var body: some View { - Text("홈") + VStack { + Text("홈") + Text(errorMessage) + } + .task { + do { + let response: BaseDTO = try await provider.async.request(.getRegions) + } catch { + print(error.localizedDescription) + errorMessage = error.localizedDescription + } + } } } diff --git a/DOKI/Presentation/Login/View/LoginView.swift b/DOKI/Presentation/Login/View/LoginView.swift index 398f4959..4773a3fa 100644 --- a/DOKI/Presentation/Login/View/LoginView.swift +++ b/DOKI/Presentation/Login/View/LoginView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AuthenticationServices struct LoginView: View { @StateObject var viewModel: LoginViewModel @@ -33,11 +34,17 @@ struct LoginView: View { } .padding(.horizontal, 16) - AppleLoginButton { - - } + AppleLoginButton {} .padding(.top, 8) .padding(.horizontal, 16) + .overlay( + SignInWithAppleButton( + onRequest: viewModel.requestAppleLogin(_:), + onCompletion: viewModel.onCompleteAppleLogin(_:) + ) + .frame(height: 50) + .blendMode(.destinationOver) + ) } } .overlay(alignment: .trailing) { diff --git a/DOKI/Presentation/Login/ViewModel/LoginViewModel.swift b/DOKI/Presentation/Login/ViewModel/LoginViewModel.swift index 52a6a65f..b1366567 100644 --- a/DOKI/Presentation/Login/ViewModel/LoginViewModel.swift +++ b/DOKI/Presentation/Login/ViewModel/LoginViewModel.swift @@ -6,15 +6,41 @@ // import SwiftUI +import AuthenticationServices class LoginViewModel: ObservableObject { private let loginCoordinator: Coordinator + private let authManager: AuthManager - init(loginCoordinator: Coordinator) { + init(loginCoordinator: Coordinator, + authManager: AuthManager = .shared) { self.loginCoordinator = loginCoordinator + self.authManager = authManager } - func navigateToRegister() { + /// 유저정보 등록화면으로 이동 + func navigateToRegister() { loginCoordinator.push(.register) } + + /// Apple 로그인 요청 + func requestAppleLogin(_ request :ASAuthorizationAppleIDRequest) { + request.requestedScopes = [.fullName, .email] + } + + /// Apple 로그인 요청 완료 + func onCompleteAppleLogin(_ result: Result) { + switch result { + case .success(let authResult): + if let appleIDCredential = authResult.credential as? ASAuthorizationAppleIDCredential, + let identityTokenData = appleIDCredential.identityToken, + let identityToken = String(data: identityTokenData, encoding: .utf8) { + Task { + await authManager.loginWithApple(identityToken, deviceId: "doki-service") + } + } + case .failure(let error): + print(error.localizedDescription) + } + } } diff --git a/DOKI/Presentation/Register/View/RegisterView.swift b/DOKI/Presentation/Register/View/RegisterView.swift index 0b6eb8cf..857f73ca 100644 --- a/DOKI/Presentation/Register/View/RegisterView.swift +++ b/DOKI/Presentation/Register/View/RegisterView.swift @@ -59,7 +59,7 @@ extension RegisterView { private var mainButton: some View { MainButton(text: viewModel.isLastStep ? "완료" : "다음", buttonState: viewModel.buttonDisabled ? .disabled : .active1) { if viewModel.isLastStep { - authManager.login() + // TODO: 홈으로 이동 } else { viewModel.goToNextStep() }