iT邦幫忙

2021 iThome 鐵人賽

DAY 22
0
Mobile Development

在 iOS 開發路上的大小事系列 第 22

【在 iOS 開發路上的大小事-Day22】透過 Firebase 來管理使用者 (Sign in with Apple 篇) Part2

  • 分享至 

  • xImage
  •  

溫馨回顧

在前一篇我們有簡單介紹了 Sign in with Apple 是什麼,有哪些使用限制
以及完成了 Sign in with Apple 的前置作業
像是在 Xcode 內新增 Sign in with Apple 的 Capability、Firebase Auth 內啟用 Apple 登入
這篇我們要來將功能實作出來~

開始實作~

首先先引入 Firebase Auth、AuthenticationServices、CryptoKit 這三個

import FirebaseAuth // 用來與 Firebase Auth 進行串接用的
import AuthenticationServices // Sign in with Apple 的主體框架
import CryptoKit // 用來產生隨機字串 (Nonce) 的

接著我們來建立一個 Sign in with Apple 的按鈕,並且可以根據系統模式來變更顯示顏色
淺色模式就顯示黑色的,深色模式就顯示白色的

按鈕樣式可以參考 Apple 官方文件:Buttons
或者是可以透過 Apple 官方提供的線上設計來模擬:Sign in with Apple Button

override func viewDidLoad() {
    super.viewDidLoad()
    setSignInWithAppleBtn()
}

// MARK: - 在畫面上產生 Sign in with Apple 按鈕
func setSignInWithAppleBtn() {
    let signInWithAppleBtn = ASAuthorizationAppleIDButton(authorizationButtonType: .signIn, authorizationButtonStyle: chooseAppleButtonStyle())
    view.addSubview(signInWithAppleBtn)
    signInWithAppleBtn.cornerRadius = 25
    signInWithAppleBtn.addTarget(self, action: #selector(signInWithApple), for: .touchUpInside)
    signInWithAppleBtn.translatesAutoresizingMaskIntoConstraints = false
    signInWithAppleBtn.heightAnchor.constraint(equalToConstant: 50).isActive = true
    signInWithAppleBtn.widthAnchor.constraint(equalToConstant: 280).isActive = true
    signInWithAppleBtn.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
    signInWithAppleBtn.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -70).isActive = true
}

func chooseAppleButtonStyle() -> ASAuthorizationAppleIDButton.Style {
    return (UITraitCollection.current.userInterfaceStyle == .light) ? .black : .white // 淺色模式就顯示黑色的按鈕,深色模式就顯示白色的按鈕
}

接下來是 請求登入的動作

// MARK: - Sign in with Apple 登入
fileprivate var currentNonce: String?

@objc func signInWithApple() {
    let nonce = randomNonceString()
    currentNonce = nonce
    let appleIDProvider = ASAuthorizationAppleIDProvider()
    let request = appleIDProvider.createRequest()
    request.requestedScopes = [.fullName, .email]
    request.nonce = sha256(nonce)

    let authorizationController = ASAuthorizationController(authorizationRequests: [request])
    authorizationController.delegate = self
    authorizationController.presentationContextProvider = self
    authorizationController.performRequests()
}

我們需要為每個請求登入時都產生一個隨機字串 (Nonce)
來確保說我們取得的每個 ID Token 都是只用來進行該 App 的身份驗證請求使用
這個對於防止 Replay attacks (重送攻擊) 是很重要的

private func randomNonceString(length: Int = 32) -> String {
    precondition(length > 0)
    let charset: Array<Character> = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
    var result = ""
    var remainingLength = length

    while(remainingLength > 0) {
        let randoms: [UInt8] = (0 ..< 16).map { _ in
            var random: UInt8 = 0
            let errorCode = SecRandomCopyBytes(kSecRandomDefault, 1, &random)
            if (errorCode != errSecSuccess) {
                fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)")
            }
            return random
        }

        randoms.forEach { random in
            if (remainingLength == 0) {
                return
            }

            if (random < charset.count) {
                result.append(charset[Int(random)])
                remainingLength -= 1
            }
        }
    }
    return result
}

private func sha256(_ input: String) -> String {
    let inputData = Data(input.utf8)
    let hashedData = SHA256.hash(data: inputData)
    let hashString = hashedData.compactMap {
        return String(format: "%02x", $0)
    }.joined()
    return hashString
}

接下來是實作 ASAuthorizationControllerDelegate 的環節
這個環節是用來進行登入成功與登入失敗的邏輯處理

extension SignInWithAppleVC: ASAuthorizationControllerDelegate {
    func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
        // 登入成功
        if let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential {
            guard let nonce = currentNonce else {
                fatalError("Invalid state: A login callback was received, but no login request was sent.")
            }
            guard let appleIDToken = appleIDCredential.identityToken else {
                CustomFunc.customAlert(title: "", message: "Unable to fetch identity token", vc: self, actionHandler: nil)
                return
            }
            guard let idTokenString = String(data: appleIDToken, encoding: .utf8) else {
                CustomFunc.customAlert(title: "", message: "Unable to serialize token string from data\n\(appleIDToken.debugDescription)", vc: self, actionHandler: nil)
                return
            }
            // 產生 Apple ID 登入的 Credential
            let credential = OAuthProvider.credential(withProviderID: "apple.com", idToken: idTokenString, rawNonce: nonce)
            // 與 Firebase Auth 進行串接
            firebaseSignInWithApple(credential: credential)
        }
    }
    
    func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
        // 登入失敗,處理 Error
        switch error {
        case ASAuthorizationError.canceled:
            CustomFunc.customAlert(title: "使用者取消登入", message: "", vc: self, actionHandler: nil)
            break
        case ASAuthorizationError.failed:
            CustomFunc.customAlert(title: "授權請求失敗", message: "", vc: self, actionHandler: nil)
            break
        case ASAuthorizationError.invalidResponse:
            CustomFunc.customAlert(title: "授權請求無回應", message: "", vc: self, actionHandler: nil)
            break
        case ASAuthorizationError.notHandled:
            CustomFunc.customAlert(title: "授權請求未處理", message: "", vc: self, actionHandler: nil)
            break
        case ASAuthorizationError.unknown:
            CustomFunc.customAlert(title: "授權失敗,原因不知", message: "", vc: self, actionHandler: nil)
            break
        default:
            break
        }
    }
}

接下來是實作 ASAuthorizationControllerPresentationContextProviding 的環節
這個環節是用來告訴說要在哪個畫面上呈現授權畫面

// MARK: - ASAuthorizationControllerPresentationContextProviding
// 在畫面上顯示授權畫面
extension SignInWithAppleVC: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        return view.window!
    }
}

接下來是透過 Credential 與 Firebase Auth 進行串接

extension SignInWithAppleVC {
    // MARK: - 透過 Credential 與 Firebase Auth 串接
    func firebaseSignInWithApple(credential: AuthCredential) {
        Auth.auth().signIn(with: credential) { authResult, error in
            guard error == nil else {
                CustomFunc.customAlert(title: "", message: "\(String(describing: error!.localizedDescription))", vc: self, actionHandler: nil)
                return
            }
            CustomFunc.customAlert(title: "登入成功!", message: "", vc: self, actionHandler: self.getFirebaseUserInfo)
        }
    }
    
    // MARK: - Firebase 取得登入使用者的資訊
    func getFirebaseUserInfo() {
        let currentUser = Auth.auth().currentUser
        guard let user = currentUser else {
            CustomFunc.customAlert(title: "無法取得使用者資料!", message: "", vc: self, actionHandler: nil)
            return
        }
        let uid = user.uid
        let email = user.email
        CustomFunc.customAlert(title: "使用者資訊", message: "UID:\(uid)\nEmail:\(email!)", vc: self, actionHandler: nil)
    }
}

然後如果要監聽目前登入狀況的話,Apple 提供了主動與被動這兩種方法
下面這個是主動方法

// MARK: - 監聽目前的 Apple ID 的登入狀況
// 主動監聽
func checkAppleIDCredentialState(userID: String) {
    ASAuthorizationAppleIDProvider().getCredentialState(forUserID: userID) { credentialState, error in
        switch credentialState {
        case .authorized:
            CustomFunc.customAlert(title: "使用者已授權!", message: "", vc: self, actionHandler: nil)
        case .revoked:
            CustomFunc.customAlert(title: "使用者憑證已被註銷!", message: "請到\n「設定 → Apple ID → 密碼與安全性 → 使用 Apple ID 的 App」\n將此 App 停止使用 Apple ID\n並再次使用 Apple ID 登入本 App!", vc: self, actionHandler: nil)
        case .notFound:
            CustomFunc.customAlert(title: "", message: "使用者尚未使用過 Apple ID 登入!", vc: self, actionHandler: nil)
        case .transferred:
            CustomFunc.customAlert(title: "請與開發者團隊進行聯繫,以利進行使用者遷移!", message: "", vc: self, actionHandler: nil)
        default:
            break
        }
    }
}

下面這個是被動方法,無論是使用 Apple ID 登入或登出都會觸發
但我在測試的時候,什麼都沒發生,可能還需要去找一下問題

// 被動監聽 (使用 Apple ID 登入或登出都會觸發)
func observeAppleIDState() {
    NotificationCenter.default.addObserver(forName: ASAuthorizationAppleIDProvider.credentialRevokedNotification, object: nil, queue: nil) { (notification: Notification) in
        CustomFunc.customAlert(title: "使用者登入或登出", message: "", vc: self, actionHandler: nil)
    }
}

成果

Yes

本篇的範例程式碼:Github

參考資料

  1. Sign In with Apple(Apple 登入)-法蘭克的iOS世界
  2. 如何整合 Sign in with Apple 到自己的 iOS App 上 (iOS & Backend)-兔子
  3. Authenticate Using Apple on iOS-Firebase Auth 官方文件

上一篇
【在 iOS 開發路上的大小事-Day21】透過 Firebase 來管理使用者 (Sign in with Apple 篇) Part1
下一篇
【在 iOS 開發路上的大小事-Day23】透過 Firebase 將多種 OAuth 身份驗證方式連結在單一帳號上
系列文
在 iOS 開發路上的大小事30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言