diff --git a/DevLog.xcodeproj/project.pbxproj b/DevLog.xcodeproj/project.pbxproj index 6342b918..d2bdc23d 100644 --- a/DevLog.xcodeproj/project.pbxproj +++ b/DevLog.xcodeproj/project.pbxproj @@ -355,7 +355,7 @@ "@executable_path/Frameworks", ); LOCALIZED_STRING_SWIFTUI_SUPPORT = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = opfic.DevLog; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -402,7 +402,7 @@ "@executable_path/Frameworks", ); LOCALIZED_STRING_SWIFTUI_SUPPORT = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = opfic.DevLog; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/DevLog/Infra/Common/InfraLayerError.swift b/DevLog/Infra/Common/InfraLayerError.swift index 4bbd9c67..a11af19c 100644 --- a/DevLog/Infra/Common/InfraLayerError.swift +++ b/DevLog/Infra/Common/InfraLayerError.swift @@ -27,6 +27,19 @@ enum UIError: Error { enum EmailFetchError: Error, Equatable { case emailNotFound case emailMismatch + + var code: String { + switch self { + case .emailMismatch: + "email_mismatch" + case .emailNotFound: + "email_not_found" + } + } +} + +enum TokenError: Error { + case invalidResponse } enum SocialLoginError: Error { diff --git a/DevLog/Infra/Service/SocialLogin/GithubAuthenticationService.swift b/DevLog/Infra/Service/SocialLogin/GithubAuthenticationService.swift index 869c4ee7..991e5974 100644 --- a/DevLog/Infra/Service/SocialLogin/GithubAuthenticationService.swift +++ b/DevLog/Infra/Service/SocialLogin/GithubAuthenticationService.swift @@ -152,7 +152,7 @@ final class GithubAuthenticationService: NSObject, AuthenticationService { } @MainActor - func requestAuthorizationCode() async throws -> String { + private func requestAuthorizationCode() async throws -> String { guard let clientID = Bundle.main.object(forInfoDictionaryKey: "GITHUB_CLIENT_ID") as? String, let redirectURL = Bundle.main.object(forInfoDictionaryKey: "APP_REDIRECT_URL") as? String, let urlComponents = URLComponents(string: redirectURL), @@ -212,19 +212,24 @@ final class GithubAuthenticationService: NSObject, AuthenticationService { } // Firebase Function 호출: Custom Token 발급 - func requestTokens(authorizationCode: String) async throws -> (String, String) { + private func requestTokens(authorizationCode: String) async throws -> (String, String) { let requestTokenFunction = functions.httpsCallable(FunctionName.requestGithubTokens) - let result = try await requestTokenFunction.call(["code": authorizationCode]) - - if let data = result.data as? [String: Any], - let accessToken = data["accessToken"] as? String, - let customToken = data["customToken"] as? String { - return (accessToken, customToken) + + do { + let result = try await requestTokenFunction.call(["code": authorizationCode]) + + if let data = result.data as? [String: Any], + let accessToken = data["accessToken"] as? String, + let customToken = data["customToken"] as? String { + return (accessToken, customToken) + } + throw TokenError.invalidResponse + } catch { + throw mapRequestTokensError(error) } - throw URLError(.badServerResponse) } - func revokeAccessToken(accessToken: String? = nil) async throws { + private func revokeAccessToken(accessToken: String? = nil) async throws { var param: [String: Any] = [:] if let accessToken = accessToken { @@ -237,8 +242,12 @@ final class GithubAuthenticationService: NSObject, AuthenticationService { } // GitHub API로 사용자 프로필 정보 가져오기 - func requestUserProfile(accessToken: String) async throws -> GitHubUser { - var request = URLRequest(url: URL(string: "https://api.github.com/user")!) + private func requestUserProfile(accessToken: String) async throws -> GitHubUser { + guard let url = URL(string: "https://api.github.com/user") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) request.httpMethod = "GET" request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.addValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") @@ -251,15 +260,62 @@ final class GithubAuthenticationService: NSObject, AuthenticationService { } let decoder = JSONDecoder() - return try decoder.decode(GitHubUser.self, from: data) + let gitHubUser = try decoder.decode(GitHubUser.self, from: data) + + if gitHubUser.email != nil { + return gitHubUser + } + + let email = try await requestPrimaryVerifiedEmail(accessToken: accessToken) + return GitHubUser( + login: gitHubUser.login, + name: gitHubUser.name, + avatarURL: gitHubUser.avatarURL, + email: email + ) } -} -extension GithubAuthenticationService: ASWebAuthenticationPresentationContextProviding { - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - return provider.keyWindow() ?? ASPresentationAnchor() + private func requestPrimaryVerifiedEmail(accessToken: String) async throws -> String? { + guard let url = URL(string: "https://api.github.com/user/emails") else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + let decoder = JSONDecoder() + let gitHubEmails = try decoder.decode([GitHubEmail].self, from: data) + + if let primaryVerifiedEmail = gitHubEmails.first(where: { $0.primary && $0.verified }) { + return primaryVerifiedEmail.email + } + + return gitHubEmails.first(where: { $0.verified })?.email } + private func mapRequestTokensError(_ error: Error) -> Error { + let nsError = error as NSError + guard nsError.domain == FunctionsErrorDomain, + let details = nsError.userInfo[FunctionsErrorDetailsKey] as? [String: Any], + let reason = details["reason"] as? String, + reason == EmailFetchError.emailNotFound.code else { + return error + } + + return EmailFetchError.emailNotFound + } +} + +private extension GithubAuthenticationService { struct GitHubUser: Codable { let login: String let name: String? @@ -274,4 +330,15 @@ extension GithubAuthenticationService: ASWebAuthenticationPresentationContextPro } } + struct GitHubEmail: Codable { + let email: String + let primary: Bool + let verified: Bool + } +} + +extension GithubAuthenticationService: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return provider.keyWindow() ?? ASPresentationAnchor() + } } diff --git a/DevLog/Presentation/ViewModel/LoginViewModel.swift b/DevLog/Presentation/ViewModel/LoginViewModel.swift index 98470d49..a5a41507 100644 --- a/DevLog/Presentation/ViewModel/LoginViewModel.swift +++ b/DevLog/Presentation/ViewModel/LoginViewModel.swift @@ -12,12 +12,13 @@ final class LoginViewModel: Store { struct State: Equatable { var isLoading = false var showAlert: Bool = false + var alertType: AlertType? var alertTitle: String = "" var alertMessage: String = "" } enum Action { - case setAlert(Bool) + case setAlert(Bool, AlertType? = nil) case tapSignInButton(AuthProvider) case setLoading(Bool) } @@ -26,6 +27,11 @@ final class LoginViewModel: Store { case signIn(AuthProvider) } + enum AlertType { + case emailUnavailable + case error + } + private let signInUseCase: SignInUseCase private let loadingState = LoadingState() @@ -42,8 +48,8 @@ final class LoginViewModel: Store { var effects: [SideEffect] = [] switch action { - case .setAlert(let isPresented): - setAlert(&state, isPresented: isPresented) + case .setAlert(let isPresented, let alertType): + setAlert(&state, isPresented: isPresented, alertType: alertType) case .tapSignInButton(let authProvider): effects = [.signIn(authProvider)] case .setLoading(let value): @@ -64,7 +70,7 @@ final class LoginViewModel: Store { try await self.signInUseCase.execute(authProvider) } catch { if error.isSocialLoginCancelled { return } - send(.setAlert(true)) + send(.setAlert(true, alertType(for: error))) } } } @@ -75,10 +81,30 @@ private extension LoginViewModel { func setAlert( _ state: inout State, isPresented: Bool, + alertType: AlertType?, ) { - state.alertTitle = String(localized: "common_error_title") - state.alertMessage = String(localized: "common_error_message") + switch alertType { + case .emailUnavailable: + state.alertTitle = String(localized: "login_alert_email_unavailable_title") + state.alertMessage = String(localized: "login_alert_email_unavailable_message") + case .error: + state.alertTitle = String(localized: "common_error_title") + state.alertMessage = String(localized: "common_error_message") + case .none: + state.alertTitle = "" + state.alertMessage = "" + } state.showAlert = isPresented + state.alertType = alertType + } + + func alertType(for error: Error) -> AlertType { + if let emailFetchError = error as? EmailFetchError, + emailFetchError == .emailNotFound { + return .emailUnavailable + } + + return .error } func beginLoading(_ mode: LoadingState.Mode) { diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 4a057543..62d9b01d 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -125,7 +125,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Email Unavailable" + "value" : "Unable to Verify Email" } }, "ko" : { @@ -537,6 +537,40 @@ } } }, + "login_alert_email_unavailable_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The selected GitHub account's email could not be verified, so sign in could not be completed. Check the GitHub account settings and try again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택한 GitHub 계정의 이메일 정보를 확인할 수 없어 로그인할 수 없어요. GitHub 계정 설정을 확인한 뒤 다시 시도해주세요." + } + } + } + }, + "login_alert_email_unavailable_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to Verify Email" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이메일 확인 불가" + } + } + } + }, "login_google_sign_in" : { "extractionState" : "manual", "localizations" : { diff --git a/Firebase/functions/src/auth/github.ts b/Firebase/functions/src/auth/github.ts index d1fc7b05..b6eebf1a 100644 --- a/Firebase/functions/src/auth/github.ts +++ b/Firebase/functions/src/auth/github.ts @@ -2,6 +2,32 @@ import {onCall, HttpsError} from "firebase-functions/v2/https"; import * as admin from "firebase-admin"; import axios from "axios"; +// GitHub OAuth 응답 타입 정의 +interface GitHubOAuthResponse { + access_token: string; + token_type: string; + scope: string; + error?: string; +} + +// GitHub 사용자 정보 응답 타입 정의 +interface GitHubUser { + id: number; + login: string; + name?: string; + email?: string; + avatar_url?: string; +} + +// GitHub 이메일 목록 응답 타입 정의 +interface GitHubEmail { + email: string; + primary: boolean; + verified: boolean; +} + +const GITHUB_EMAIL_UNAVAILABLE_REASON = "email_not_found"; + // GitHub OAuth 인증 및 커스텀 토큰 발급 함수 export const requestGithubTokens = onCall({ cors: true, @@ -23,14 +49,6 @@ export const requestGithubTokens = onCall({ throw new HttpsError('internal', 'GitHub 환경 설정이 누락되었습니다.'); } - // GitHub OAuth 응답 타입 정의 - interface GitHubOAuthResponse { - access_token: string; - token_type: string; - scope: string; - error?: string; - } - // 1. GitHub OAuth 토큰 획득 const tokenResponse = await axios.post ('https://github.com/login/oauth/access_token', { @@ -48,15 +66,6 @@ export const requestGithubTokens = onCall({ const accessToken = tokenData.access_token; - // GitHub 사용자 정보 응답 타입 정의 - interface GitHubUser { - id: number; - login: string; - name?: string; - email?: string; - avatar_url?: string; - } - // 2. GitHub 사용자 정보 가져오기 const userResponse = await axios.get('https://api.github.com/user', { headers: { @@ -65,22 +74,28 @@ export const requestGithubTokens = onCall({ }); const userData = userResponse.data; - if (!userData.id || !userData.email) { - throw new HttpsError('internal', 'GitHub 사용자 데이터를 가져오지 못했습니다.'); + const email = await resolveGitHubEmail(accessToken, userData.email); + + if (!userData.id || !email) { + throw new HttpsError( + 'internal', + 'GitHub 사용자 데이터를 가져오지 못했습니다.', + { reason: GITHUB_EMAIL_UNAVAILABLE_REASON } + ); } // 3. Firebase에서 GitHub 제공자로 사용자를 찾거나 생성 let uid; try { - const userRecord = await admin.auth().getUserByEmail(userData.email); + const userRecord = await admin.auth().getUserByEmail(email); uid = userRecord.uid; // 기존 UID 사용 - console.log(`이메일(${userData.email})로 기존 사용자를 찾았습니다.`); + console.log(`이메일(${email})로 기존 사용자를 찾았습니다.`); } catch (error) { // 사용자가 없으면 Firebase에 새 사용자 생성 const userRecord = await admin.auth().createUser({ displayName: userData.name || userData.login, - email: userData.email, + email, photoURL: userData.avatar_url, }); uid = userRecord.uid; // 새로 생성된 UID 사용 @@ -97,10 +112,38 @@ export const requestGithubTokens = onCall({ }; } catch (error) { console.error('GitHub 커스텀 토큰 생성 오류:', error); + if (error instanceof HttpsError) { + throw error; + } throw new HttpsError('internal', error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'); } }); +async function resolveGitHubEmail( + accessToken: string, + profileEmail?: string +): Promise { + if (profileEmail) { + return profileEmail; + } + + const emailResponse = await axios.get('https://api.github.com/user/emails', { + headers: { + 'Authorization': `token ${accessToken}` + } + }); + + const primaryVerifiedEmail = emailResponse.data.find((item) => + item.primary && item.verified + )?.email + + if (primaryVerifiedEmail) { + return primaryVerifiedEmail; + } + + return emailResponse.data.find((item) => item.verified)?.email; +} + export const revokeGithubAccessToken = onCall({ cors: true, maxInstances: 3, diff --git a/README.md b/README.md index 1b72ef7b..ae2c3302 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ ## 앱 사용해보기 -iOS 17 이상 환경에서 TestFlight 베타 테스트 가능 +iOS 17 이상 환경에서 App Store에서 다운로드 가능 + + + - - - ## 프로젝트 개요