diff --git a/Keyme.entitlements b/Keyme.entitlements index cc78b75e..9ff04609 100644 --- a/Keyme.entitlements +++ b/Keyme.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.applesignin + + Default + com.apple.developer.associated-domains diff --git a/Projects/Features/Sources/Root/RootFeature.swift b/Projects/Features/Sources/Root/RootFeature.swift index ca100073..b4ebceb9 100644 --- a/Projects/Features/Sources/Root/RootFeature.swift +++ b/Projects/Features/Sources/Root/RootFeature.swift @@ -3,6 +3,8 @@ // Features // // Created by 이영빈 on 2023/08/10. +// Edited by 고도 on 2023/08/14. +// // Copyright © 2023 team.humanwave. All rights reserved. // @@ -54,14 +56,13 @@ public struct RootFeature: Reducer { Reduce { state, action in switch action { case .login(.presented(let result)): - switch result { - case .succeeded: - localStorage.set(true, forKey: .isLoggedIn) - state.logInStatus = .loggedIn - case .failed: - localStorage.set(false, forKey: .isLoggedIn) - state.logInStatus = .loggedOut - } +// switch result { +// case .succeeded: +// localStorage.set(true, forKey: .isLoggedIn) +// state.logInStatus = .loggedIn +// case .failed: +// localStorage.set(false, forKey: .isLoggedIn) +// state.logInStatus = .loggedOut return .none case .onboarding(.presented(let result)): diff --git a/Projects/Features/Sources/SignIn/SignInFeature.swift b/Projects/Features/Sources/SignIn/SignInFeature.swift index 712f8b08..d05106f9 100644 --- a/Projects/Features/Sources/SignIn/SignInFeature.swift +++ b/Projects/Features/Sources/SignIn/SignInFeature.swift @@ -3,13 +3,24 @@ // Features // // Created by 이영빈 on 2023/08/10. +// Edited by 고도 on 2023/08/14. +// // Copyright © 2023 team.humanwave. All rights reserved. // -import Foundation +import AuthenticationServices import ComposableArchitecture +import Foundation +import KakaoSDKUser +import Network + +public enum SignInError: Error { + case noSignIn +} public struct SignInFeature: Reducer { + @Dependency(\.localStorage) private var localStorage + public enum State: Equatable { case notDetermined case loggedIn @@ -17,13 +28,122 @@ public struct SignInFeature: Reducer { } public enum Action: Equatable { - case succeeded - case failed + case signInWithKakao + case signInWithKakaoResponse(TaskResult) + + case signInWithApple(AppleOAuthResponse) + case signInWithAppleResponse(TaskResult) + // case succeeded + // case failed } public var body: some Reducer { Reduce { state, action in + switch action { + case .signInWithKakao: + return .run { send in + await send(.signInWithKakaoResponse( + TaskResult { + try await signInWithKakao() + } + )) + } + + case .signInWithKakaoResponse(.success(true)): // 카카오 로그인 성공 + state = .loggedIn + localStorage.set(true, forKey: .isLoggedIn) + return .none + + case .signInWithKakaoResponse(.failure): // 카카오 로그인 실패 + state = .loggedOut + return .none + + case .signInWithApple(let appleOAuth): + return .run { send in + await send(.signInWithAppleResponse( + TaskResult { + await signInWithApple(appleOAuth) + } + )) + } + + case .signInWithAppleResponse(.success(true)): // 애플 로그인 성공 + state = .loggedIn + localStorage.set(true, forKey: .isLoggedIn) + return .none + + case .signInWithAppleResponse(.failure): // 애플 로그인 실패 + state = .loggedOut + return .none + + default: + state = .loggedOut + } + return .none } } } + +extension SignInFeature { + // 카카오 로그인 메서드 + /// Reducer Closure 내부에서 State를 직접 변경할 수 없어서 Async - Await를 활용하여 한 번 더 이벤트(signInWithKakaoResponse)를 발생시키도록 구현했습니다. + private func signInWithKakao() async throws -> Bool { + return try await withCheckedThrowingContinuation { continuation in + if (UserApi.isKakaoTalkLoginAvailable()) { + UserApi.shared.loginWithKakaoTalk { (token, error) in + if let error = error { + continuation.resume(throwing: SignInError.noSignIn) + } else { + continuation.resume(returning: true) + } + } + } else { + UserApi.shared.loginWithKakaoAccount() { (data, error) in + if let error = error { + continuation.resume(throwing: SignInError.noSignIn) + } else { + do { + // 1. 카카오 API로 사용자 정보 가져오기 + let jsonData = try JSONEncoder().encode(data) + let parsedData = try JSONDecoder().decode(KakaoOAuthResponse.self, from: jsonData) + + // 2. Keyme API로 사용자 토큰 확인하기 + Task { + do { + let auth = KeymeOAuthRequest(oauthType: "KAKAO", token: parsedData.accessToken) + let result = try await KeymeAPIManager.shared.request(.auth(param: auth), object: KeymeOAuthResponse.self) + + if result.code == 200 { + return continuation.resume(returning: true) + } else { + return continuation.resume(throwing: SignInError.noSignIn) + } + } catch { // 에러가 발생하면 실패 처리 + return continuation.resume(throwing: SignInError.noSignIn) + } + } + } catch { // 에러가 발생하면 실패 처리 + continuation.resume(throwing: SignInError.noSignIn) + } + } + } + } + } + } + + private func signInWithApple(_ appleOAuth: AppleOAuthResponse) async -> Bool { + do { + let auth = KeymeOAuthRequest(oauthType: "APPLE", token: appleOAuth.identifyToken!) // FIXME: 강제 언래핑 + let result = try await KeymeAPIManager.shared.request(.auth(param: auth), object: KeymeOAuthResponse.self) + + if result.code == 200 { + return true + } else { + return false + } + } catch { + return false + } + } +} diff --git a/Projects/Features/Sources/SignIn/SignInView.swift b/Projects/Features/Sources/SignIn/SignInView.swift index 11a2f082..faba2c76 100644 --- a/Projects/Features/Sources/SignIn/SignInView.swift +++ b/Projects/Features/Sources/SignIn/SignInView.swift @@ -3,13 +3,16 @@ // Keyme // // Created by 이영빈 on 2023/08/10. +// Edited by 고도 on 2023/08/14. +// // Copyright © 2023 team.humanwave. All rights reserved. // -import SwiftUI +import AuthenticationServices import ComposableArchitecture +import SwiftUI +import Network -// FIXME: Temp public struct SignInView: View { private let store: StoreOf @@ -18,8 +21,103 @@ public struct SignInView: View { } public var body: some View { - Button(action: { store.send(.succeeded) }) { - Text("로그인?") + VStack(alignment: .center, spacing: 0) { + Spacer() + + KakaoLoginButton(store: store) + + AppleLoginButton(store: store) + + GuideMessageView() + } + .padding() + } + + // 카카오 로그인 버튼 + struct KakaoLoginButton: View { + let store: StoreOf + + var body: some View { + Button(action: { + store.send(.signInWithKakao) + }) { + Image("kakao_login") + .resizable() + .scaledToFill() + } + .frame(width: 312, height: 48) + .cornerRadius(6) + } + } + + // 애플 로그인 버튼 + struct AppleLoginButton: View { + let store: StoreOf + + var body: some View { + SignInWithAppleButton( + onRequest: { request in + request.requestedScopes = [.fullName, .email] + }, + onCompletion: { completion in + switch completion { + case .success(let response): + switch response.credential { // FIXME: 추후에 SignInFeature으로 이동 + case let appleIDCredential as ASAuthorizationAppleIDCredential: + let user = appleIDCredential.user + let fullName = appleIDCredential.fullName + let name = (fullName?.familyName ?? "") + (fullName?.givenName ?? "") + let email = appleIDCredential.email + let identifyToken = String(data: appleIDCredential.identityToken!, encoding: .utf8) + let authorizationCode = String(data: appleIDCredential.authorizationCode!, encoding: .utf8) + let appleOAuth = AppleOAuthResponse(user: user, + fullName: fullName, + name: name, + email: email, + identifyToken: identifyToken, + authorizationCode: authorizationCode) + store.send(.signInWithApple(appleOAuth)) + default: + store.send(.signInWithAppleResponse(.failure(SignInError.noSignIn))) + } + case .failure: + store.send(.signInWithAppleResponse(.failure(SignInError.noSignIn))) + } + + }) + .signInWithAppleButtonStyle(.white) + .frame(width: 312, height: 48) + .cornerRadius(6) + .padding(.vertical) + } + } + + struct GuideMessageView: View { + var body: some View { + VStack(spacing: 8) { + Text("가입 시, 키미의 다음 사항에 동의하는 것으로 간주합니다.") + .foregroundColor(.gray) + + HStack(spacing: 4) { + Button(action: {}) { + Text("서비스 이용약관") + .fontWeight(.bold) + .foregroundColor(.white) + } + + Text("및") + .foregroundColor(.gray) + + Button(action: {}) { + Text("개인정보 정책") + .fontWeight(.bold) + .foregroundColor(.white) + } + .foregroundColor(.white) + } + } + .font(.system(size: 11)) + .frame(width: 265, height: 36) } } } diff --git a/Projects/Keyme/Resources/Assets.xcassets/apple_login_white.imageset/Contents.json b/Projects/Keyme/Resources/Assets.xcassets/apple_login_white.imageset/Contents.json new file mode 100644 index 00000000..60958d61 --- /dev/null +++ b/Projects/Keyme/Resources/Assets.xcassets/apple_login_white.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "apple_login_white.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Keyme/Resources/Assets.xcassets/apple_login_white.imageset/apple_login_white.png b/Projects/Keyme/Resources/Assets.xcassets/apple_login_white.imageset/apple_login_white.png new file mode 100644 index 00000000..5f33bcf8 Binary files /dev/null and b/Projects/Keyme/Resources/Assets.xcassets/apple_login_white.imageset/apple_login_white.png differ diff --git a/Projects/Keyme/Resources/Assets.xcassets/kakao_login.imageset/Contents.json b/Projects/Keyme/Resources/Assets.xcassets/kakao_login.imageset/Contents.json new file mode 100644 index 00000000..5eb4ba1b --- /dev/null +++ b/Projects/Keyme/Resources/Assets.xcassets/kakao_login.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "kakao_login.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Keyme/Resources/Assets.xcassets/kakao_login.imageset/kakao_login.png b/Projects/Keyme/Resources/Assets.xcassets/kakao_login.imageset/kakao_login.png new file mode 100644 index 00000000..c0c18561 Binary files /dev/null and b/Projects/Keyme/Resources/Assets.xcassets/kakao_login.imageset/kakao_login.png differ diff --git a/Projects/Keyme/Sources/KeymeApp.swift b/Projects/Keyme/Sources/KeymeApp.swift index e1a614b5..5f575c85 100644 --- a/Projects/Keyme/Sources/KeymeApp.swift +++ b/Projects/Keyme/Sources/KeymeApp.swift @@ -8,13 +8,27 @@ import FirebaseMessaging import Features import Network +import KakaoSDKAuth +import KakaoSDKCommon + @main struct KeymeApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + let KAKAO_PRIVATE_KEY = "" // 🚨 SECRET 🚨 + + init() { + KakaoSDK.initSDK(appKey: KAKAO_PRIVATE_KEY) + } + var body: some Scene { WindowGroup { RootView() + .onOpenURL(perform: { url in + if (AuthApi.isKakaoTalkLoginUrl(url)) { + AuthController.handleOpenUrl(url: url) + } + }) } } } diff --git a/Projects/Network/Sources/DTO/Auth.swift b/Projects/Network/Sources/DTO/Auth.swift new file mode 100644 index 00000000..ca034b67 --- /dev/null +++ b/Projects/Network/Sources/DTO/Auth.swift @@ -0,0 +1,85 @@ +// +// Auth.swift +// Network +// +// Created by 고도현 on 2023/08/15. +// Copyright © 2023 team.humanwave. All rights reserved. +// + +import Foundation + +public struct KakaoOAuthResponse: Codable { // 카카오 API Response 관련 Model + public let refreshTokenExpiresIn: Int + public let tokenType: String + public let refreshToken: String + public let accessToken: String + public let expiresIn: Int + public let scope: String + + public init(refreshTokenExpiresIn: Int, + tokenType: String, + refreshToken: String, + accessToken: String, + expiresIn: Int, + scope: String) { + self.refreshTokenExpiresIn = refreshTokenExpiresIn + self.tokenType = tokenType + self.refreshToken = refreshToken + self.accessToken = accessToken + self.expiresIn = expiresIn + self.scope = scope + } +} + +public struct AppleOAuthResponse: Codable, Equatable { // 애플 API Response 관련 Model + public let user: String + public let fullName: PersonNameComponents? + public let name: String + public let email: String? + public let identifyToken: String? + public let authorizationCode: String? + + public init(user: String, + fullName: PersonNameComponents?, + name: String, + email: String?, + identifyToken: String?, + authorizationCode: String?) { + self.user = user + self.fullName = fullName + self.name = name + self.email = email + self.identifyToken = identifyToken + self.authorizationCode = authorizationCode + } +} + +public struct KeymeOAuthRequest: Codable { // KeyMe API Request 관련 Model + public let oauthType: String + public let token: String + + public init(oauthType: String, + token: String) { + self.oauthType = oauthType + self.token = token + } +} + +public struct KeymeOAuthResponse: Codable { // KeyMe API Response 관련 Model + public let code: Int // statusCode + public let data: Data + public let message: String + + public struct Data: Codable { + public let id: Int + public let friendCode: String? + public let nickname: String? + public let profileImage: String? + public let profileTumbnail: String? + public let token: Token + + public struct Token: Codable { + public let accessToken: String + } + } +} diff --git a/Projects/Network/Sources/Network/Foundation/KeymeAPI.swift b/Projects/Network/Sources/Network/Foundation/KeymeAPI.swift index 37b667f3..80c39ef1 100644 --- a/Projects/Network/Sources/Network/Foundation/KeymeAPI.swift +++ b/Projects/Network/Sources/Network/Foundation/KeymeAPI.swift @@ -14,6 +14,7 @@ public enum KeymeAPI { case test case myPage(MyPage) case registerPushToken(String) + case auth(param: KeymeOAuthRequest) } public enum MyPage { @@ -38,9 +39,11 @@ extension KeymeAPI: BaseAPI { return "/members/\(id)/statistics" case .registerPushToken: return "/members/devices" + case .auth: + return "/auth/login" } } - + public var method: Moya.Method { switch self { case .test: @@ -49,9 +52,11 @@ extension KeymeAPI: BaseAPI { return .get case .registerPushToken: return .post + case .auth: + return .post } } - + public var task: Task { switch self { case .test: @@ -60,13 +65,15 @@ extension KeymeAPI: BaseAPI { return .requestParameters(parameters: ["type": type.rawValue], encoding: URLEncoding.default) case .registerPushToken(let token): return .requestParameters(parameters: ["token": token], encoding: JSONEncoding.default) + case .auth(let param): + return .requestJSONEncodable(param) } } - + public var headers: [String: String]? { return ["Content-type": "application/json"] } - + public var sampleData: Data { """ { diff --git a/Tuist/Signing/Keyme.DEV.mobileprovision b/Tuist/Signing/Keyme.DEV.mobileprovision index de476695..ea85df2b 100644 Binary files a/Tuist/Signing/Keyme.DEV.mobileprovision and b/Tuist/Signing/Keyme.DEV.mobileprovision differ diff --git a/Tuist/Signing/Keyme.PROD.mobileprovision b/Tuist/Signing/Keyme.PROD.mobileprovision index 7f2b7d09..79880aae 100644 Binary files a/Tuist/Signing/Keyme.PROD.mobileprovision and b/Tuist/Signing/Keyme.PROD.mobileprovision differ