diff --git a/Projects/App/Sources/Application/GPleApp.swift b/Projects/App/Sources/Application/GPleApp.swift index 0966051..d57b80b 100644 --- a/Projects/App/Sources/Application/GPleApp.swift +++ b/Projects/App/Sources/Application/GPleApp.swift @@ -10,15 +10,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - @main struct GoogleSignInProjectApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate + @StateObject var viewModel: LoginViewModel = LoginViewModel() @StateObject var userInfoViewModel: UserInfoViewModel = UserInfoViewModel() var body: some Scene { WindowGroup { - UserInfoView(viewModel: UserInfoViewModel()) + LoginView(viewModel: LoginViewModel(), userInfoViewModel: userInfoViewModel) } } } diff --git a/Projects/App/Sources/Feature/LoginFeature/Sources/LoginButton.swift b/Projects/App/Sources/Feature/LoginFeature/Sources/LoginButton.swift index 9df1280..6759c5e 100644 --- a/Projects/App/Sources/Feature/LoginFeature/Sources/LoginButton.swift +++ b/Projects/App/Sources/Feature/LoginFeature/Sources/LoginButton.swift @@ -6,6 +6,7 @@ struct LoginButton: View { var body: some View { Button { loginViewModel.signIn() + } label: { VStack { HStack(spacing: 12) { @@ -26,8 +27,6 @@ struct LoginButton: View { .background(GPleAsset.Color.white.swiftUIColor) .cornerRadius(12) .padding(.horizontal, 20) - } - } } diff --git a/Projects/App/Sources/Feature/LoginFeature/Sources/LoginView.swift b/Projects/App/Sources/Feature/LoginFeature/Sources/LoginView.swift index a51f414..198c681 100644 --- a/Projects/App/Sources/Feature/LoginFeature/Sources/LoginView.swift +++ b/Projects/App/Sources/Feature/LoginFeature/Sources/LoginView.swift @@ -2,6 +2,7 @@ import SwiftUI struct LoginView: View { @StateObject var viewModel: LoginViewModel + @StateObject var userInfoViewModel: UserInfoViewModel var body: some View { ZStack { @@ -32,6 +33,10 @@ struct LoginView: View { LoginButton(loginViewModel: viewModel) .padding(.bottom, 40) } + + if viewModel.isSignedIn == true { + UserInfoView(viewModel: UserInfoViewModel()) + } } } } diff --git a/Projects/App/Sources/Feature/LoginFeature/Sources/LoginViewModel.swift b/Projects/App/Sources/Feature/LoginFeature/Sources/LoginViewModel.swift index 64d5379..431b6b7 100644 --- a/Projects/App/Sources/Feature/LoginFeature/Sources/LoginViewModel.swift +++ b/Projects/App/Sources/Feature/LoginFeature/Sources/LoginViewModel.swift @@ -6,7 +6,7 @@ import Domain final class LoginViewModel: ObservableObject { @Published var isSignedIn: Bool = false @Published var errorMessage: String? - + private let provider = MoyaProvider() private func getGoogleClientID() -> String? { @@ -19,11 +19,13 @@ final class LoginViewModel: ObservableObject { return clientID } + @MainActor func signIn() { guard let clientID = getGoogleClientID() else { print("Google Client ID is missing.") return } + let configuration = GIDConfiguration(clientID: clientID) GIDSignIn.sharedInstance.configuration = configuration @@ -46,31 +48,64 @@ final class LoginViewModel: ObservableObject { return } + print("CILENT_ID: \(clientID)") print("ID Token: \(idToken)") self?.sendIdTokenToServer(idToken) } } + @MainActor private func sendIdTokenToServer(_ idToken: String) { provider.request(.login(idToken: idToken)) { [weak self] result in switch result { - case .success(let response): + case let .success(res): do { - if let responseData = try JSONSerialization.jsonObject(with: response.data, options: []) as? [String: Any] { - print("Response: \(responseData)") - DispatchQueue.main.async { - self?.isSignedIn = true - } + + let loginResponse = try JSONDecoder().decode(Token.self, from: res.data) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + + if let accessTokenExpirationDate = dateFormatter.date(from: loginResponse.accessExpiredAt), + let refreshTokenExpirationDate = dateFormatter.date(from: loginResponse.refreshExpiredAt) { + + let accessTokenExpirationInterval = accessTokenExpirationDate.timeIntervalSinceNow + let refreshTokenExpirationInterval = refreshTokenExpirationDate.timeIntervalSinceNow + + KeyChain.shared.saveTokenWithExpiration( + key: Const.KeyChainKey.accessToken, + token: loginResponse.accessToken, + expiresIn: accessTokenExpirationInterval + ) + + KeyChain.shared.saveTokenWithExpiration( + key: Const.KeyChainKey.refreshToken, + token: loginResponse.refreshToken, + expiresIn: refreshTokenExpirationInterval + ) + + print("Access Token 만료 날짜: \(accessTokenExpirationDate)") + print("Refresh Token 만료 날짜: \(refreshTokenExpirationDate)") + print("토큰 저장 완료") } else { - DispatchQueue.main.async { - self?.errorMessage = "Unexpected response format." - } + print("만료 시간 변환 오류: 유효한 날짜 형식이 아님.") + } + print("------------------------------------------------------------") + + DispatchQueue.main.async { + print("Access Token: \(loginResponse.accessToken)") + print("Refresh Token: \(loginResponse.refreshToken)") + print("Access Token Expiry: \(loginResponse.accessExpiredAt)") + print("Refresh Token Expiry: \(loginResponse.refreshExpiredAt)") + self?.isSignedIn = true } } catch { DispatchQueue.main.async { self?.errorMessage = "Failed to parse response: \(error.localizedDescription)" } } + case .failure(let error): DispatchQueue.main.async { self?.errorMessage = "Request failed: \(error.localizedDescription)" diff --git a/Projects/App/Sources/Feature/UserInfoFeature/Sources/UserInfoView.swift b/Projects/App/Sources/Feature/UserInfoFeature/Sources/UserInfoView.swift index ed751d1..988655f 100644 --- a/Projects/App/Sources/Feature/UserInfoFeature/Sources/UserInfoView.swift +++ b/Projects/App/Sources/Feature/UserInfoFeature/Sources/UserInfoView.swift @@ -82,7 +82,9 @@ struct UserInfoView: View { backColor: GPleAsset.Color.gray1000.swiftUIColor, buttonState: viewModel.name.isEmpty == false && viewModel.number.isEmpty == false, buttonOkColor: GPleAsset.Color.main.swiftUIColor - ) + ) { + viewModel.submitUserInfo() + } .padding(.bottom, 12) } } diff --git a/Projects/App/Sources/Feature/UserInfoFeature/Sources/UserInfoViewModel.swift b/Projects/App/Sources/Feature/UserInfoFeature/Sources/UserInfoViewModel.swift index dd2ff7d..a72f6a3 100644 --- a/Projects/App/Sources/Feature/UserInfoFeature/Sources/UserInfoViewModel.swift +++ b/Projects/App/Sources/Feature/UserInfoFeature/Sources/UserInfoViewModel.swift @@ -1,6 +1,59 @@ import Foundation +import Combine +import Moya +import Domain final class UserInfoViewModel: ObservableObject { + // 입력된 사용자 정보 @Published var name: String = "" @Published var number: String = "" + + // 네트워크 요청 상태를 나타내는 퍼블리셔 + @Published var isLoading: Bool = false + @Published var errorMessage: String? = nil + + // Moya 프로바이더 초기화 + private let provider = MoyaProvider() + + private func fetchAccessToken() -> String? { + return KeyChain.shared.read(key: Const.KeyChainKey.accessToken) + } + + // 사용자 정보 제출 함수 + func submitUserInfo() { + // 로딩 상태 시작 + isLoading = true + errorMessage = nil + print("name: \(name), number: \(number)") + + // 인증 토큰 (실제 앱에서는 안전한 저장소에서 가져와야 함) + guard let authorizationToken = fetchAccessToken() else { return } + + // API 호출 + provider.request(.userInfoInput(authorization: authorizationToken, name: name, number: number, file: nil)) { [weak self] result in + // 로딩 상태 종료 + DispatchQueue.main.async { + self?.isLoading = false + } + + switch result { + case .success(let response): + // 서버 응답 처리 + if (200...299).contains(response.statusCode) { + // 성공 처리 + print("사용자 정보가 성공적으로 전송되었습니다.") + } else { + // 서버 오류 메시지 처리 + DispatchQueue.main.async { + self?.errorMessage = "서버 오류: \(response.statusCode)" + } + } + case .failure(let error): + // 네트워크 오류 처리 + DispatchQueue.main.async { + self?.errorMessage = "네트워크 오류: \(error.localizedDescription)" + } + } + } + } } diff --git a/Projects/Domain/Sources/API/Auth/AuthAPI.swift b/Projects/Domain/Sources/API/Auth/AuthAPI.swift index f188a0b..eefcd5b 100644 --- a/Projects/Domain/Sources/API/Auth/AuthAPI.swift +++ b/Projects/Domain/Sources/API/Auth/AuthAPI.swift @@ -3,33 +3,23 @@ import Moya public enum AuthAPI { case login(idToken: String) - case logout - case refresh + case logout(refreshToken: String) + case refresh(idToken: String) } -extension AuthAPI: GPleAPI { - public var domain: GPleDomain { - .auth +extension AuthAPI: TargetType { + public var baseURL: URL { + return URL(string: "https://port-0-gple-backend-eg4e2alkoplc4q.sel4.cloudtype.app")! } - public var urlPath: String { + public var path: String { switch self { case .login: - return "/google/login" + return "/auth/google/token" case .logout: - return "/logout" + return "/auth/logout" case .refresh: - return "/" - } - } - - public var jwtTokenType: JwtTokenType { - switch self { - case .login, .refresh: - return .refreshToken - - case .logout: - return .accessToken + return "/auth/" } } @@ -48,13 +38,23 @@ extension AuthAPI: GPleAPI { public var task: Task { switch self { - case let .login(idToken): + case let .login(idToken), let .refresh(idToken): return .requestParameters(parameters: [ - "idTonek" : idToken + "idToken" : idToken ], encoding: JSONEncoding.default) - case .logout, .refresh: + case .logout: return .requestPlain } } + + public var headers: [String : String]? { + switch self { + case + .logout(let refreshToken): + return ["Content-Type": "application/json", "refreshToken": refreshToken] + default: + return ["Content-Type": "application/json"] + } + } } diff --git a/Projects/Domain/Sources/API/User/UserAPI.swift b/Projects/Domain/Sources/API/User/UserAPI.swift index 79240d2..f9af183 100644 --- a/Projects/Domain/Sources/API/User/UserAPI.swift +++ b/Projects/Domain/Sources/API/User/UserAPI.swift @@ -1,58 +1,59 @@ -//import Foundation -//import Moya -// -//public enum UserAPI { -// case userInfoInput(authorization: String, name: String, number: String, file: Data) -//} -// -//extension UserAPI: TargetType { -// public var baseURL: URL { -// return URL(string: "https://port-0-gple-backend-eg4e2alkoplc4q.sel4.cloudtype.app")! -// } -// -// public var path: String { -// switch self { -// case .userInfoInput: -// return "/profile" -// } -// } -// -// public var method: Moya.Method { -// switch self { -// case .userInfoInput: -// return .post -// } -// } -// -// public var sampleData: Data { -// return "{}".data(using: .utf8)! -// } -// -// public var task: Task { -// switch self { -// case .userInfoInput(let authorization, let name, let number, let file): -// // Form data (name, number) and file upload using multipart -// return .requestCompositeParameters( -// bodyParameters: [ -// "name": name, -// "number": number -// ], -// bodyEncoding: JSONEncoding.default, // Form data with JSON encoding -// urlParameters: [:], // No URL parameters -// multipartBody: [ -// MultipartFormData(provider: .data(file), name: "file", fileName: "profile_image.jpg", mimeType: "image/jpeg") -// ] -// ) -// } -// } -// -// public var headers: [String : String]? { -// switch self { -// case .userInfoInput(let authorization, _, _, _): -// return [ -// "Authorization": "Bearer \(authorization)", // Authorization token -// "Content-Type": "multipart/form-data" // Set content type for multipart requests -// ] -// } -// } -//} +import Foundation +import Moya + +public enum UserAPI { + case userInfoInput(authorization: String, name: String, number: String, file: Data?) +} + +extension UserAPI: TargetType { + public var baseURL: URL { + return URL(string: "https://port-0-gple-backend-eg4e2alkoplc4q.sel4.cloudtype.app")! + } + + public var path: String { + switch self { + case .userInfoInput: + return "/user/profile" + } + } + + public var method: Moya.Method { + switch self { + case .userInfoInput: + return .post + } + } + + public var sampleData: Data { + return "{}".data(using: .utf8)! + } + + public var task: Task { + switch self { + case let .userInfoInput(authorization, name, number, file): + // Form data (name, number) and file upload using multipart + var formData: [MultipartFormData] = [ + MultipartFormData(provider: .data(name.data(using: .utf8)!), name: "name"), + MultipartFormData(provider: .data(number.data(using: .utf8)!), name: "number") + ] + + // 이미지가 존재하면 추가 + if let fileData = file { + let fileFormData = MultipartFormData(provider: .data(fileData), name: "image", fileName: "profile_image.jpg", mimeType: "image/jpeg") + formData.append(fileFormData) + } + + return .uploadMultipart(formData) + } + } + + public var headers: [String : String]? { + switch self { + case .userInfoInput(let authorization, _, _, _): + return [ + "Authorization": "Bearer \(authorization)", // Authorization token + "Content-Type": "multipart/form-data" // Set content type for multipart requests + ] + } + } +} diff --git a/Projects/Domain/Sources/Base/API/RefreshAPI.swift b/Projects/Domain/Sources/Base/API/RefreshAPI.swift deleted file mode 100644 index bf7a293..0000000 --- a/Projects/Domain/Sources/Base/API/RefreshAPI.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Moya - -public enum RefreshAPI { - case reissueToken -} - -extension RefreshAPI: GPleAPI { - public typealias ErrorType = RefreshError - - public var domain: GPleDomain { - .auth - } - - public var urlPath: String { - switch self { - case .reissueToken: - return "/reissue" - } - } - - public var method: Moya.Method { - switch self { - case .reissueToken: - return .patch - } - } - - public var task: Moya.Task { - switch self { - default: - return .requestPlain - } - } - - public var jwtTokenType: JwtTokenType { - switch self { - case .reissueToken: - return .refreshToken - } - } - - public var errorMap: [Int: ErrorType] { - switch self { - case .reissueToken: - return [ - 401: .unauthorized, - 404: .notFound - ] - } - } -} diff --git a/Projects/Domain/Sources/Base/DataSources/BaseRemoteDataSource.swift b/Projects/Domain/Sources/Base/DataSources/BaseRemoteDataSource.swift deleted file mode 100644 index eee806e..0000000 --- a/Projects/Domain/Sources/Base/DataSources/BaseRemoteDataSource.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import Moya - -open class BaseRemoteDataSource { - private let keychain: any Keychain - private let provider: MoyaProvider - private let decoder = JSONDecoder() - private let maxRetryCount = 2 - - public init( - keychain: any Keychain, - provider: MoyaProvider? = nil - ) { - self.keychain = keychain - - #if DEV || STAGE - self.provider = provider ?? MoyaProvider(plugins: [JwtPlugin(keychain: keychain), MoyaLoggingPlugin()]) - #else - self.provider = provider ?? MoyaProvider(plugins: [JwtPlugin(keychain: keychain), MoyaLoggingPlugin()]) - #endif - } - - public func request(_ api: API, dto: T.Type) async throws -> T { - let response = try await retryingRequest(api) - return try decoder.decode(dto, from: response.data) - } - - public func request(_ api: API) async throws { - _ = try await retryingRequest(api) - } - - private func requestPublisher(_ api: API) async throws -> Response { - try await withCheckedThrowingContinuation { continuation in - self.provider.request(api) { result in - switch result { - case .success(let response): - continuation.resume(returning: response) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func retryingRequest(_ api: API) async throws -> Response { - for _ in 0...sleep(nanoseconds: delay) - continue - } - } - throw MyError.tooManyRetries - } - - enum MyError: Error { - case tooManyRetries - } -} diff --git a/Projects/Domain/Sources/Base/Enum/UserAuthorityType.swift b/Projects/Domain/Sources/Base/Enum/UserAuthorityType.swift deleted file mode 100644 index ce73178..0000000 --- a/Projects/Domain/Sources/Base/Enum/UserAuthorityType.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -public enum UserAuthorityType: String, CaseIterable, Decodable, Encodable { - case user = "ROLE_USER" - case admin = "ROLE_ADMIN" -} diff --git a/Projects/Domain/Sources/Base/Error/RefreshError.swift b/Projects/Domain/Sources/Base/Error/RefreshError.swift deleted file mode 100644 index 411137b..0000000 --- a/Projects/Domain/Sources/Base/Error/RefreshError.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public enum RefreshError: Error { - case unauthorized - case notFound -} - -extension RefreshError: LocalizedError { - public var errorDescription: String? { - switch self { - case .unauthorized: - return "Unauthorized" - case .notFound: - return "NotFound" - } - } -} diff --git a/Projects/Domain/Sources/Base/GPleAPI.swift b/Projects/Domain/Sources/Base/GPleAPI.swift deleted file mode 100644 index 5923396..0000000 --- a/Projects/Domain/Sources/Base/GPleAPI.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Moya -import Foundation - -public protocol GPleAPI: TargetType, JwtAuthorizable { -// associatedtype ErrorType: Error - var domain: GPleDomain { get } - var urlPath: String { get } -} - -public extension GPleAPI { -// var baseURL: URL { -// URL( -// string: Bundle.module.object(forInfoDictionaryKey: "BASE_URL") as? String ?? "" -// ) ?? URL(string: "https://www.google.com")! -// } - - var baseURL: URL { - return URL(string: "https://active-weasel-fluent.ngrok-free.app")! - } - - var path: String { - domain.asURLString + urlPath - } - - var headers: [String : String]? { - ["Content-Type": "application/json"] - } - - var validationType: ValidationType { - return .successCodes - } -} - -public enum GPleDomain: String { - case auth -} - -extension GPleDomain { - var asURLString: String { - "\(self.rawValue)" - } -} - -private class BundleFinder {} - -extension Foundation.Bundle { - static let module = Bundle(for: BundleFinder.self) -} diff --git a/Projects/Domain/Sources/Base/Plugins/Jwt/JwtAuthorizable.swift b/Projects/Domain/Sources/Base/Plugins/Jwt/JwtAuthorizable.swift deleted file mode 100644 index d08aafd..0000000 --- a/Projects/Domain/Sources/Base/Plugins/Jwt/JwtAuthorizable.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Moya - -public enum JwtTokenType: String { - case accessToken = "Authorization" - case refreshToken = "RefreshToken" - case none -} - -public protocol JwtAuthorizable { - var jwtTokenType: JwtTokenType { get } -} diff --git a/Projects/Domain/Sources/Base/Plugins/Jwt/JwtPlugin.swift b/Projects/Domain/Sources/Base/Plugins/Jwt/JwtPlugin.swift deleted file mode 100644 index 87fd9cf..0000000 --- a/Projects/Domain/Sources/Base/Plugins/Jwt/JwtPlugin.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Moya -import Foundation - -public struct JwtPlugin: PluginType { - private let keychain: any Keychain - - public init(keychain: any Keychain) { - self.keychain = keychain - } - - public func prepare( - _ request: URLRequest, - target: TargetType - ) -> URLRequest { - guard let jwtTokenType = (target as? JwtAuthorizable)?.jwtTokenType, - jwtTokenType != .none - else { return request } - var req = request - let token = "\(getToken(type: jwtTokenType == .accessToken ? .accessToken : .refreshToken))" - - req.addValue(token, forHTTPHeaderField: jwtTokenType.rawValue) - return req - } - - public func didReceive( - _ result: Result, - target: TargetType - ) { - switch result { - case let .success(res): - if let new = try? res.map(TokenDTO.self) { - saveToken(token: new) - } - default: - break - } - } -} - -private extension JwtPlugin { - func getToken(type: KeychainType) -> String { - switch type { - case .accessToken: - return "Bearer \(keychain.load(type: .accessToken))" - - case .refreshToken: - return keychain.load(type: .refreshToken) - - case .accessExpiredAt: - return keychain.load(type: .accessExpiredAt) - - case .refreshExpiredAt: - return keychain.load(type: .refreshExpiredAt) - } - } - - func saveToken(token: TokenDTO) { - keychain.save(type: .accessToken, value: token.accessToken) - keychain.save(type: .refreshToken, value: token.refreshToken) - keychain.save(type: .accessExpiredAt, value: token.accessExpiredAt) - keychain.save(type: .refreshExpiredAt, value: token.refreshExpiredAt) - } -} diff --git a/Projects/Domain/Sources/Base/Plugins/Jwt/TokenDTO.swift b/Projects/Domain/Sources/Base/Plugins/Jwt/TokenDTO.swift deleted file mode 100644 index 42f6006..0000000 --- a/Projects/Domain/Sources/Base/Plugins/Jwt/TokenDTO.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -struct TokenDTO: Equatable, Decodable { - let accessToken: String - let refreshToken: String - let accessExpiredAt: String - let refreshExpiredAt: String - - enum CodingKeys: String, CodingKey { - case accessToken = "accessToken" - case refreshToken = "refreshToken" - case accessExpiredAt = "accessExpiredAt" - case refreshExpiredAt = "refreshExpiredAt" - } -} diff --git a/Projects/Domain/Sources/Base/Plugins/Logging/MoyaLoggingPlugin.swift b/Projects/Domain/Sources/Base/Plugins/Logging/MoyaLoggingPlugin.swift deleted file mode 100644 index 76d7464..0000000 --- a/Projects/Domain/Sources/Base/Plugins/Logging/MoyaLoggingPlugin.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import Moya - -#if DEBUG -// swiftlint: disable line_length -public final class MoyaLoggingPlugin: PluginType { - public init() {} - public func willSend(_ request: RequestType, target: TargetType) { - guard let httpRequest = request.request else { - print("--> 유효하지 않은 요청") - return - } - let url = httpRequest.description - let method = httpRequest.httpMethod ?? "unknown method" - var log = - "----------------------------------------------------\n\n[\(method)] \(url)\n\n----------------------------------------------------\n" - log.append("API: \(target)\n") - if let headers = httpRequest.allHTTPHeaderFields, !headers.isEmpty { - log.append("header: \(headers)\n") - } - if let body = httpRequest.httpBody, let bodyString = String(bytes: body, encoding: String.Encoding.utf8) { - log.append("\(bodyString)\n") - } - log.append("------------------- END \(method) --------------------------\n") - print(log) - } - - public func didReceive(_ result: Result, target: TargetType) { - switch result { - case let .success(response): - onSuceed(response, target: target, isFromError: false) - case let .failure(error): - onFail(error, target: target) - } - } - - func onSuceed(_ response: Response, target: TargetType, isFromError: Bool) { - let request = response.request - let url = request?.url?.absoluteString ?? "nil" - let statusCode = response.statusCode - var log = "------------------- 네트워크 통신 성공 -------------------" - log.append("\n[\(statusCode)] \(url)\n----------------------------------------------------\n") - log.append("API: \(target)\n") - response.response?.allHeaderFields.forEach { - log.append("\($0): \($1)\n") - } - if let reString = String(bytes: response.data, encoding: String.Encoding.utf8) { - log.append("\(reString)\n") - } - log.append("------------------- END HTTP (\(response.data.count)-byte body) -------------------\n") - print(log) - } - - func onFail(_ error: MoyaError, target: TargetType) { - if let response = error.response { - onSuceed(response, target: target, isFromError: true) - return - } - var log = "네트워크 오류" - log.append("<-- \(error.errorCode) \(target)\n") - log.append("\(error.failureReason ?? error.errorDescription ?? "unknown error")\n") - log.append("<-- END HTTP\n") - print(log) - } -} - -#endif diff --git a/Projects/Domain/Sources/Extension/Token.swift b/Projects/Domain/Sources/Extension/Token.swift new file mode 100644 index 0000000..c0225fa --- /dev/null +++ b/Projects/Domain/Sources/Extension/Token.swift @@ -0,0 +1,119 @@ +import Moya +import Security +import Foundation + +// 토큰과 만료 기간을 포함하는 구조체 +struct TokenData: Codable { + let token: String + let expirationDate: Date +} + +public class KeyChain { + public static let shared = KeyChain() + + // 토큰 저장하기 (만료 기간과 함께) + func create(key: String, token: String) { + // 먼저 토큰을 데이터로 변환하고 만료 기간을 설정 + if let tokenData = token.data(using: .utf8) { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key, + kSecValueData: tokenData + ] + SecItemDelete(query) // 기존 데이터 삭제 + let status = SecItemAdd(query, nil) + assert(status == noErr, "failed to save Token") + } + } + + // 토큰과 만료 기간을 JSON으로 저장하기 + public func saveTokenWithExpiration(key: String, token: String, expiresIn: TimeInterval) { + let expirationDate = Date().addingTimeInterval(expiresIn) // 만료일 계산 + let tokenData = TokenData(token: token, expirationDate: expirationDate) + + if let encodedData = try? JSONEncoder().encode(tokenData), + let tokenString = String(data: encodedData, encoding: .utf8) { + create(key: key, token: tokenString) + print("Token 저장 완료: \(tokenString)") + } else { + print("TokenData 인코딩 실패") + } + } + + // 저장된 토큰 읽기 (만료 기간 포함) + public func read(key: String) -> String? { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key, + kSecReturnData: kCFBooleanTrue as Any, + kSecMatchLimit: kSecMatchLimitOne + ] + + var dataTypeRef: AnyObject? + let status = SecItemCopyMatching(query, &dataTypeRef) + + if status == errSecSuccess { + if let retrievedData: Data = dataTypeRef as? Data { + let value = String(data: retrievedData, encoding: .utf8) + return value + } else { return nil } + } else { + print("failed to loading, status code = \(status)") + return nil + } + } + + // 토큰과 만료 기간을 함께 읽기 + func loadTokenWithExpiration(key: String) -> TokenData? { + if let tokenString = read(key: key), + let tokenData = tokenString.data(using: .utf8) { + return try? JSONDecoder().decode(TokenData.self, from: tokenData) + } + return nil + } + + // 만료 여부 확인 + func isTokenExpired(key: String) -> Bool { + if let tokenData = loadTokenWithExpiration(key: key) { + return tokenData.expirationDate < Date() // 현재 시간과 만료일 비교 + } + return true // 토큰이 없으면 만료된 것으로 간주 + } + + // 토큰 업데이트하기 + func updateItem(token: Any, key: Any) -> Bool { + let prevQuery: [CFString: Any] = [kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key] + let updateQuery: [CFString: Any] = [kSecValueData: (token as AnyObject).data(using: String.Encoding.utf8.rawValue) as Any] + + let result: Bool = { + let status = SecItemUpdate(prevQuery as CFDictionary, updateQuery as CFDictionary) + if status == errSecSuccess { return true } + + print("updateItem Error : \(status.description)") + return false + }() + + return result + } + + // 토큰 삭제 + func delete(key: String) { + let query: NSDictionary = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: key + ] + + let status = SecItemDelete(query) + if status != errSecSuccess { + print("Failed to delete item from Keychain with status code \(status)") + } + } +} + +public struct Const { + public struct KeyChainKey { + public static let accessToken = "accessToken" + public static let refreshToken = "refreshToken" + } +} diff --git a/Projects/Domain/Sources/Keychain/Sources/Keychain.swift b/Projects/Domain/Sources/Keychain/Sources/Keychain.swift deleted file mode 100644 index 5a20c98..0000000 --- a/Projects/Domain/Sources/Keychain/Sources/Keychain.swift +++ /dev/null @@ -1,12 +0,0 @@ -public enum KeychainType: String { - case accessToken = "ACCESS-TOKEN" - case refreshToken = "REFRESH-TOKEN" - case accessExpiredAt = "ACCESS-EXPIRED-AT" - case refreshExpiredAt = "REFRESH-EXPIRED-AT" -} - -public protocol Keychain { - func save(type: KeychainType, value: String) - func load(type: KeychainType) -> String - func delete(type: KeychainType) -} diff --git a/Projects/Domain/Sources/Keychain/Sources/KeychainFake.swift b/Projects/Domain/Sources/Keychain/Sources/KeychainFake.swift deleted file mode 100644 index cda52e1..0000000 --- a/Projects/Domain/Sources/Keychain/Sources/KeychainFake.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -final class KeychainFake: Keychain { - var store: [String: String] = [:] - - func save(type: KeychainType, value: String) { - store[type.rawValue] = value - } - - func load(type: KeychainType) -> String { - store[type.rawValue] ?? "" - } - - func delete(type: KeychainType) { - store[type.rawValue] = nil - } -} diff --git a/Projects/Domain/Sources/Keychain/Sources/KeychainImpl.swift b/Projects/Domain/Sources/Keychain/Sources/KeychainImpl.swift deleted file mode 100644 index 8c8b7da..0000000 --- a/Projects/Domain/Sources/Keychain/Sources/KeychainImpl.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Foundation - -public struct KeychainImpl: Keychain { - public init() {} - - public func save(type: KeychainType, value: String) { - let query: NSDictionary = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: type.rawValue, - kSecValueData: value.data(using: .utf8, allowLossyConversion: false) ?? .init(), - ] - SecItemDelete(query) - SecItemAdd(query, nil) - } - - public func load(type: KeychainType) -> String { - let query: NSDictionary = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: type.rawValue, - kSecReturnData: kCFBooleanTrue!, - kSecMatchLimit: kSecMatchLimitOne, - ] - var dataTypeRef: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) - if status == errSecSuccess { - guard let data = dataTypeRef as? Data else { return "" } - return String(data: data, encoding: .utf8) ?? "" - } - return "" - } - - public func delete(type: KeychainType) { - let query: NSDictionary = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: type.rawValue, - ] - SecItemDelete(query) - } -} diff --git a/Projects/Domain/Sources/Request/User/UserInfoRequest.swift b/Projects/Domain/Sources/Request/User/UserInfoRequest.swift new file mode 100644 index 0000000..432014d --- /dev/null +++ b/Projects/Domain/Sources/Request/User/UserInfoRequest.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct UserInfoRequest: Codable { + var name: String + var number: String + var file: Data? + + public init(name: String, number: String, file: Data?) { + self.name = name + self.number = number + self.file = file + } + + public init(name: String, number: String) { + self.name = name + self.number = number + } +} diff --git a/Projects/Domain/Sources/Response/Auth/FetchLoginResponse.swift b/Projects/Domain/Sources/Response/Auth/FetchLoginResponse.swift new file mode 100644 index 0000000..d6c3dfe --- /dev/null +++ b/Projects/Domain/Sources/Response/Auth/FetchLoginResponse.swift @@ -0,0 +1,6 @@ +public struct Token: Codable { + public let accessToken: String + public let accessExpiredAt: String + public let refreshToken: String + public let refreshExpiredAt: String +}