2024-06-23|閱讀時間 ‧ 約 39 分鐘

技術筆記-iOS實戰004-整合Firebase,使用它的Google SignIn做身份認證

需求情境:

為了讓多人使用 App,必須有驗證程序,以識別特定使用者,存取各自擁有的資源。

解決方案:

引用 google 所提供的雲端服務平台 Firebase,其中有多種驗證功能可選用。基於個人對 google 的偏愛,決定先採用 google signin 的方法,實作 login logout,並用 idToken 作為與後台 api server 溝通的驗證依據,測通呼叫流程。

實作步驟:

必須先在 Firebase 的 管理後台 準備相關資源:建立專案,在專案內建立應用,應用有很多種類型,選擇 iOS 類,各類別有相對應的 sdk 和文件。除了身份驗證以外,Firebase 作為一個應用系統的「容器」,提供非常多樣的功能,App 連上它就像擁有了一座寶庫成為堅實的後盾,真令人興奮!但這屬於後台技術故不在此詳述,若有合適的機會,再另闢新的寫作系列。


在 iOS 方面的實作工作,首先要從後台下載一個準備好的參數檔「GoogleService-Info.plist」,加到 Swift 專案內。然後用 Xcode 的套件管理員安裝 sdk,套件存放的位置在此:https://github.com/firebase/firebase-ios-sdk;此套件提供基礎的 email and password 驗證方式。因我期望用 google account 的認證方式,故還要再加上 GoogleSignIn 套件,有不同的安裝位址,均可從文件中取得。


套件安裝完成後,進到程式開發階段,需要從一個 iOS App 的程式入口開始做起,就是 appNewmanApp.swift,其中 appNewmanApp 為 App 的名稱。其中定義了一個 AppDelegate 的 class,透過特定的宣告語法,讓相關物件在程式啟動的初期就載入,在整個執行期間都能發揮效用。這些語法與物件有點高深莫測,是架構設計者最厲害的技術結晶,老實講我並不十分了解,只能先照文件依樣葫蘆。而 isRunningTests 的判斷機制是為了讓 Xcode 開發過程的 preview 不要出錯。

import SwiftUI
import SwiftData
import FirebaseCore
import FirebaseAuth
import GoogleSignIn

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        if !isRunningTests {
          FirebaseApp.configure()
        }
        return true
    }
    func application(_ app: UIApplication,
                     open url: URL,
                     options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
        return GIDSignIn.sharedInstance.handle(url)
    }
    var isRunningTests: Bool {
        return ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
    }
}

@main
struct appNewmanApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
    @StateObject private var authViewModel = AuthViewModel()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(authViewModel)
        }
    }
}

接下來定義一個 class,import 相關的函示庫後,必須注意繼承 ObservableObject,並將其最重要的兩個屬性,就是登入狀態,和當登入成功後所取得的 User 物件,標示為 Published,以便在系統的任何一個畫面都可以使用:

import FirebaseCore
import FirebaseAuth
import GoogleSignIn

class AuthViewModel: ObservableObject {
    @Published var user: User?
    @Published var isSignedIn: Bool = false
}

在此 class 裡面定義 signIn, signOut 的方法,依循「官方文件」按部就班一路操作。

func signInByGoogle() {
guard let clientID = FirebaseApp.app()?.options.clientID else { return }
let config = GIDConfiguration(clientID: clientID)
GIDSignIn.sharedInstance.configuration = config
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController 
else {
print("There is no root view controller!")
return
}
GIDSignIn.sharedInstance.signIn(withPresenting: rootViewController) { [unowned self] result, error in
if let error = error {
print("Error signing in: \(error.localizedDescription)")
return
}
// 取得外部 Google 系統所定義的 User 物件​
guard let googleUser = result?.user,
let idToken = googleUser.idToken?.tokenString
else {
return
}
let credential = GoogleAuthProvider.credential(withIDToken: idToken,
accessToken: googleUser.accessToken.tokenString)
// 用 Google User 的 credential 取得 Firebase 系統所定義的 User​
Auth.auth().signIn(with: credential) { result, error in
if let error = error {
print("Firebase sign in error: \(error.localizedDescription)")
return
}
self.user = result?.user
self.isSignedIn = true
}
}
}

func signOut() {
do {
try Auth.auth().signOut()
GIDSignIn.sharedInstance.signOut()
self.user = nil
self.isSignedIn = false
} catch let signOutError as NSError {
print("Error signing out: \(signOutError.localizedDescription)")
}
}

有一個細節必須特別注意,就是在專案屬性中的 Info 頁籤中,有一個 URL Types,必須加入一筆資料,資料來源是 GoogleService-Info.plist 中的 REVERSED_CLIENT_ID 的值。透過這個 URL 設定,讓專案允許 google 這個外部服務,更動專案內資料以執行我們所期望的 signIn, signOut 相關任務。

這種跨系統整合通常藏有非常多的坑,同樣名稱為 User 的物件卻有兩種,經由 google signIn 首先取得的是 google 系統所定義的 User 物件,無法直接使用於我們的應用系統,需要用它的 idToken and accessToken 取得 credential,再傳給 Firebase SDK 的 signIn 程序,才能取得 Firebase 所定義的 User 物件,用於程式中的畫面和後台溝通。運作機制有點複雜,最好仔細思考建立較完整的知識架構,否則後續的維護工作將是令人恐懼的!因為不管 iOS 端的升級,或是 google 端的升級,都會影響到程式結構,維護工作是無法逃避的。


最後來到畫面的部分,基本的元素就是一個 Sign In 按鈕,當 Sign In 成功之後顯示使用者頭像,並實作一個到後台取資料的程序,完成畫面如下,左一為未登入狀態,右一登入完成狀態,中間的登入過程為 Firebase SDK 和 Google 認證服務後台所主控,客製程式無法介入,如此也表示安全性由 google 官方所確保。若採用 email and password 登入,這些密碼資料是會經過我們的程式的,要讓使用者信任我們程式開發單位,不會惡意使用,這是難的,此問題還是丟給 google 處理比較妥當。

此畫面的程式命名為 LoginView.swift,主 Layout 和狀態變數列出如下:

	@EnvironmentObject var authViewModel: AuthViewModel
@State var imageURL: URL?
@State var idToken : String = ""
@State var portfolioList: [Portfolio] = []


var body: some View {
if authViewModel.isSignedIn {
VStack {
Text("Welcome, \(authViewModel.user?.email ?? "")!")
if let imageURL = imageURL {
AsyncImage(url: imageURL) { image in
image
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.clipShape(Circle())
} placeholder: {
ProgressView()
.frame(width: 100, height: 100)
}
} else {
Text("Invalid URL")
}
HStack {
Button(action: {
authViewModel.signOut()
}) {
Text("Sign Out")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
Button(action: {
getPortfolioList()
}) {
Text("持 token 向後台取資料")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
}
VStack {
ForEach(portfolioList) { item in
Text(item.portfolioName)
}
}
}
.padding()
.onAppear {
showPhoto()
}
} else {
VStack {
Button(action: {
authViewModel.signInByGoogle()
}) {
Text("Sign In")
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
}
.padding()
}
}

當 Sign In 成功時,接續執行 showPhoto(),因圖像顯示並不是靜態存在的,如同先前預先匯入專案 Asset 檔案中那樣,而是搭配畫面的 AsyncImage 元件,動態指定位址。另外我們也順便把 idToken 載入到畫面的狀態變數,以便下一個動作需要使用,就是向後台取資料,這也要用非同步的程式寫法。

func showPhoto() {
if authViewModel.isSignedIn {
imageURL = authViewModel.user?.photoURL
Task {
idToken = try await getIdToken(authViewModel: authViewModel)
}
}
}

最後是用 idToken 去後台取資料的部分,先用一個簡單的資料結構 Model1 承接,今天的重點只在測通。

func getPortfolioList() {
Task {
let data = try await  fetchApiDataAsync(urlString: "https://xxxserver/portfolioList", idToken: self.idToken)
let decoder = JSONDecoder()
let xx = try decoder.decode(Model1.self, from: data)
self.portfolioList = xx.portfolio
}
}

struct Portfolio: Codable, Identifiable {
let portfolioId: Int
let portfolioName: String
var id: Int {
portfolioId
}
}

struct Model1: Codable {
let message: String
let portfolio: [Portfolio] 
}

fetchApiDataAsync 因為多傳 idToken,寫法有小幅更改。至於後台怎麼解開 idToken 則不在本文範圍。

func fetchApiDataAsync(urlString: String, idToken: String) async throws -> Data {
guard let url = URL(string: urlString)
else {
throw MyError.FileUrlError
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("\(idToken)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw MyError.HttpRequestError
}
return data
}

以上一個普通的功能,所費工夫不少,寫起來也是又臭又長,最終完成了,就是爽快。

Newman 2024/6/23

導覽頁:紐曼的技術筆記-索引


分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.