【在 iOS 开发路上的大小事-Day22】透过 Firebase 来管理使用者 (Sign in with Apple 篇) Part2

温馨回顾

在前一篇我们有简单介绍了 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)
    }
}

成果

本篇的范例程序码: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 官方文件

<<:  Day23 - ListView

>>:  DAY19-EXCEL统计分析:何谓变异数分析?

< 关於 React: 开始打地基| useState()>

09-08-2021 本章内容 THE STATE HOOK Hook 可以做的事情 规则 使用us...

【Day30】[演算法]-线性搜寻法Linear Search

搜寻(Search) 就是从一群资料中找出符合某些条件的资料,当资料量非常庞大时,如何在短时间内有效...

前端工程师也能开发全端网页:挑战 30 天用 React 加上 Firebase 打造社群网站|Day16 文章留言区块

连续 30 天不中断每天上传一支教学影片,教你如何用 React 加上 Firebase 打造社群...

[Day30] 浅谈重构(refactoring)与两把刷子

铁人赛的最後一天,让我们先来简单的聊聊重构,这部分是笔者之前在看「大规模重构」这本书时整理的内容,目...

DAY20 - 档案处理 - 利用jszip和file-saver,制作网页下载zip档案

情境 有多个文件或图档利用checkbox勾选後,可以直接点选下载按钮。此时网站会将刚刚所选择的项目...