Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DevLog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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 = "";
Expand Down
13 changes: 13 additions & 0 deletions DevLog/Infra/Common/InfraLayerError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
74 changes: 66 additions & 8 deletions DevLog/Infra/Service/SocialLogin/GithubAuthenticationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,19 @@ final class GithubAuthenticationService: NSObject, AuthenticationService {
// Firebase Function 호출: Custom Token 발급
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 {
Expand Down Expand Up @@ -251,7 +256,54 @@ 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
)
Comment thread
opficdev marked this conversation as resolved.
}

func requestPrimaryVerifiedEmail(accessToken: String) async throws -> String? {
var request = URLRequest(url: URL(string: "https://api.github.com/user/emails")!)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

URL(string: "...")!를 사용하여 URL을 생성하고 있습니다. 만약 주어진 문자열로 URL을 생성하지 못할 경우, 이 코드는 런타임에 크래시를 발생시킬 수 있습니다. 문자열이 상수이고 올바른 형식이 보장되더라도, 안전한 코딩 관례를 위해 강제 언래핑(force unwrapping)은 피하는 것이 좋습니다.
guard let을 사용하여 URL을 안전하게 생성하고, 실패할 경우 적절한 에러를 던지도록 수정하는 것을 권장합니다.

Suggested change
var request = URLRequest(url: URL(string: "https://api.github.com/user/emails")!)
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
}

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
}
}

Expand All @@ -274,4 +326,10 @@ extension GithubAuthenticationService: ASWebAuthenticationPresentationContextPro
}
}

struct GitHubEmail: Codable {
let email: String
let primary: Bool
let verified: Bool
}
Comment thread
opficdev marked this conversation as resolved.

}
38 changes: 32 additions & 6 deletions DevLog/Presentation/ViewModel/LoginViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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()

Expand All @@ -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):
Expand All @@ -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)))
}
}
}
Expand All @@ -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) {
Expand Down
36 changes: 35 additions & 1 deletion DevLog/Resource/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Email Unavailable"
"value" : "Unable to Verify Email"
}
},
"ko" : {
Expand Down Expand Up @@ -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" : {
Expand Down
87 changes: 65 additions & 22 deletions Firebase/functions/src/auth/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<GitHubOAuthResponse>
('https://github.com/login/oauth/access_token', {
Expand All @@ -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<GitHubUser>('https://api.github.com/user', {
headers: {
Expand All @@ -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 사용
Expand All @@ -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<string | undefined> {
if (profileEmail) {
return profileEmail;
}

const emailResponse = await axios.get<GitHubEmail[]>('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,
Expand Down
Loading