在前一篇我們有簡單介紹了 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