在SwiftUI中使用Sign in with Apple

翻譯原文:Sign in with Apple

學習如何在SwiftUI中實現(xiàn)Sign in with Apple,在iOS應用中給用戶更多的隱私和控制。

Sign In with Apple 是iOS 13中的一個新特性,它會加快你的應用的注冊和認證過程。

雖然蘋果公司不斷的強調Sign In with Apple的實現(xiàn)是簡單的,但確實存在一些奇怪的地方要處理。在這個教程中,你不僅將學會如何恰當的實現(xiàn)Sign In with Apple,而且還教會你如何在SwiftUI中實現(xiàn)!

你將需要Xcode 11,一個付費的蘋果開發(fā)這賬號和iOS 13設備。

注意:你需要一臺運行iOS 13的實體機。模擬器可能無法正常工作。

開始

請點擊教程上方和下方的下載資源按鈕來下載資源文件。因為你要運行在實體機和管理一些權限問題,所以你可以通過Project navigator點擊新的Signing & Capabilities標簽來設置你的team id和bundle id。如果你立即編譯并運行app,你將看到一個正常的登錄界面:

image1

注意:你可以先忽略Xcode顯示的兩個警告。你將通過接下來的教程解決它們。

添加功能

你的provisioning profile需要開啟Sign In with Apple功能,因此立馬加上它。在Project navigator點擊項目,選擇target里的SignInWithApple,然后點擊Signing & Capabilities標簽欄。最后點擊+ Capability來添加Sign In with Apple功能。

如果你的應用也有相關的網站,你也應該添加Associated Domains功能。這個步驟在使用Sign In with Apple功能時是完全可選的,在這個教程中也不是必須的。如果你確認要使用一個相關的域名,請確認在域名欄中的值設置為webcredentials:+域名的形式。舉個例子,類似于webcredentials:www.mydomain.com,在教程的后面部分你將學習如何在你的網站上做必要的改變。

添加注冊按鈕

蘋果并沒有給SwiftUI提供Sign In with Apple按鈕,所以你需要自己包裝一個。創(chuàng)建一個新的swift文件并命名為SignInWithApple.swift,然后復制這段代碼。

import SwiftUI
import AuthenticationServices

// 1
final class SignInWithApple: UIViewRepresentable {
  // 2
  func makeUIView(context: Context) -> ASAuthorizationAppleIDButton {
    // 3
    return ASAuthorizationAppleIDButton()
  }
  
  // 4
  func updateUIView(_ uiView: ASAuthorizationAppleIDButton, context: Context) {
  }
}

解釋下這段代碼發(fā)生了什么:

  1. 當你需要包裝一個UIVIew時需要子類化UIViewRepresentable。
  2. makeUIView需要總是返回一個特定類型的UIView。
  3. 因為你并不需要實施自定義,所以直接返回Sign In with Apple即可。
  4. 因為view的狀態(tài)不會改變,所以空實現(xiàn)即可。

注意:如果你還沒有嘗試過SwiftUI,但是又想更深入的學習,請查閱這份教程

現(xiàn)在你可以將按鈕添加到SwiftUI中,打開ContentView.swift并把以下代碼添加到UserAndPassword界面下方:

SignInWithApple()
  .frame(width: 280, height: 60)

蘋果的樣式指導指出最小的尺寸為 280 * 60,所以確認要遵循。編譯并運行你的應用你應該看到這個按鈕

image3

處理按鈕點擊事件

就目前而言,我們點擊按鈕并無事發(fā)生。就在你設置按鈕的frame下方,我們添加一個手勢識別:

.onTapGesture(perform: showAppleLogin)

然后我們要在body屬性后面實現(xiàn)這個showAppleLogin()方法:

private func showAppleLogin() {
  // 1
  let request = ASAuthorizationAppleIDProvider().createRequest()

  // 2
  request.requestedScopes = [.fullName, .email]

  // 3
  let controller = ASAuthorizationController(authorizationRequests: [request])    
}

解釋下我們設置的東西:

  1. 所有的注冊請求都需要一個ASAuthorizationAppleIDRequest。
  2. 明確你需要知道的最終用戶的數據類型。
  3. 生成一個控制器來展示注冊對話框。

你應該僅僅在你真正需要用戶數據時進行請求。蘋果會為你生成一個用戶ID。所以,如果你知識想獲取用戶的郵箱啦作為唯一標識,那么其實你并不是真正的需要獲取用戶數據 —— 所以在這種情況下你不要請求它。

代理ASAuthorizationControllerDelegate

當用戶試圖去鑒定時,蘋果會調用一兩個代理方法,因此,我們現(xiàn)在就實現(xiàn)它。打開SignInWithAppleDelegates.swift文件。你講在這里實現(xiàn)當用戶點擊按鈕后運行的代碼。當然,你也可以在你想實現(xiàn)這段代碼的地方實現(xiàn),考慮到復用,將它轉移到別的地方可能會是代碼更干凈。

目前我們會講authorizationController(controller:didCompleteWithError:)方法留空,但是在真實產品中,我們需要處理這些錯誤。

當鑒定成功了,將會調起authorizationController(controller:didCompleteWithAuthorization:)協(xié)議方法。你可以在下載的樣例代碼中我們又兩種情況需要處理。通過credential屬性,我們可以檢測出用戶是通過Apple ID還是存儲在iCloud中的密碼進行的驗證。

這個傳遞給代理方法的參數ASAuthorization屬性包含了任何你想要獲取的屬性,包括郵箱或者名字。這個值的存在與否可以讓我們識別這是一個新的認證還是一個已經存在的登錄。

注意:蘋果只會在第一次授權時提供詳細的信息。

上述的注意點是需要牢記于心的!蘋果假設你在獲取到詳細信息是會存儲它而不是一次又一次請求獲取。這就是你在處理Sign In with Apple時要處理的奇怪的地方之一。

試想一下這個情況,當一個用戶在第一次注冊時,你需要執(zhí)行注冊操作,蘋果提供給你用戶的郵箱和全名。然后,你嘗試調用你服務器的注冊代碼,但是你的服務器不在線或者設備的網絡鏈接失敗了等等。

下一次用戶登錄的時候,蘋果不會再提供詳細的信息了,因為它希望你已經存儲了它們。這就會進入運行第二種“用戶已存在”的流程,如果你沒存儲,最終會導致失敗。

處理注冊流程

authorizationController(controller:didCompleteWithAuthorization:),在第一個case條件中,添加以下代碼:

// 1
if let _ = appleIdCredential.email, let _ = appleIdCredential.fullName {
  // 2
  registerNewAccount(credential: appleIdCredential)
} else {
  // 3
  signInWithExistingAccount(credential: appleIdCredential)
}

在這段代碼中:

  1. 如果你獲取到詳細的信息,你會知道這是一個新的注冊。
  2. 一旦你獲取到詳細信息,就調用你的注冊方法。
  3. 如果你沒有獲取到詳細信息,就調用你的已存在賬戶的方法。

在擴展的頂部粘貼以下這段注冊代碼:

private func registerNewAccount(credential: ASAuthorizationAppleIDCredential) {
  // 1
  let userData = UserData(email: credential.email!,
                          name: credential.fullName!,
                          identifier: credential.user)

  // 2
  let keychain = UserDataKeychain()
  do {
    try keychain.store(userData)
  } catch {
    self.signInSucceeded(false)
  }

  // 3
  do {
    let success = try WebApi.Register(
      user: userData,
      identityToken: credential.identityToken,
      authorizationCode: credential.authorizationCode
    )
    self.signInSucceeded(success)
  } catch {
    self.signInSucceeded(false)
  }
}

這段代碼中發(fā)生了這些事:

  1. 保存想要的詳細信息并將蘋果提供的user存入結構體中。
  2. 將詳細信息存入到iCloud鑰匙串中以便將來使用。
  3. 調用你服務器的服務并告訴調用者本次注冊是否成功。

注意credential.user這個用法。這個屬性包含了蘋果給最終用戶提供的唯一表示符。使用這個值——而不是郵箱后者登錄名——當你在你的服務器上存儲用戶時。所提供的值完全匹配該用戶所有跨平臺的設備。此外,蘋果也將該值提供給你的Team ID所擁有的所有的app。該用戶所運行的任何一個app都會得到相同的ID,這也就意味著該用戶在運行你的其他app時,你的服務器已近存儲了它的信息,因此你不必在要求用戶提供它!

你的服務器數據庫很可能已經為用戶存儲了一些其他的標識符。簡單的在你的用戶類型表中添加新的一欄用來存放蘋果提供的唯一標識。你的服務器端的代碼將先檢查這一欄是否匹配。如果沒有找到,則轉去你的已登錄或注冊流程,比如說使用郵箱地址或者用戶名。

鑒于你的服務器是如何處理安全問題,你可能需要或者不需要傳遞credential.identityTokencredential.authorizationCode。OAuth的流程使用這兩塊數據。建立OAuth已超出了這份指南的范圍。

注意:蘋果提供了需要生成使用OAuth的公鑰。本質上,它們提供給你一個JSON Web Key(JWK)。

為了確保存在鑰匙串里,編輯UserDataKeychain.swift里的CredentialStorage更新account使其是你的app的bundle id拼接上其他的字符串。我喜歡在bundle id后拼接上.Detail。這么做主要是為了讓account屬性和bundle id不完全一致。所以呢存儲的值只會用做你特定的目的。

處理已存在的賬戶

正如之前所描述的,當一個已存在的用戶登錄你的app,蘋果是不提供email和全名的。將這個方法直接添加在SignInWithAppleDelegate.swift中注冊方法的下方:

private func signInWithExistingAccount(credential: ASAuthorizationAppleIDCredential) {
  // You *should* have a fully registered account here.  If you get back an error
  // from your server that the account doesn't exist, you can look in the keychain 
  // for the credentials and rerun setup

  // if (WebAPI.login(credential.user, 
  //                  credential.identityToken,
  //                  credential.authorizationCode)) {
  //   ...
  // }

  self.signInSucceeded(true)
}

這個方法中的代碼是非常app向的。如果你的服務器告訴你這個用戶沒有注冊你將會接收到失敗,你需要使用retrieve()來查詢鑰匙串。通過返回的UserData結構體,你將重新為該用戶注冊。

用戶名和密碼

在使用Sign In with Apple時,還有一種可能,就是用戶選擇那些已經存在iCloud鑰匙串中的證書來登錄。在authorizationController(controller:didCompleteWithAuthorization:)的第二個case條件居中添加以下代碼:

signInWithUserAndPassword(credential: passwordCredential)

然后在signInWithExistingAccount(credential:)下實現(xiàn)對應的方法:

private func signInWithUserAndPassword(credential: ASPasswordCredential) {
  // if (WebAPI.login(credential.user, credential.password)) {
  //   ...
  // }
  self.signInSucceeded(true)
}

再一次,你的實現(xiàn)將會是非常app向的。但是,你將想通過用戶名和密碼來調用你服務器的登錄。如果服務器無法找到這個用戶,你將需要執(zhí)行整個注冊流程因為你沒有為用戶的郵箱和用戶名存儲在鑰匙串中。

完成按鈕點擊處理

回到ContentView.swift中,你需要一個屬性來放置你剛剛創(chuàng)建的代理。在類的頂部,添加以下這段代碼:

@State var appleSignInDelegates: SignInWithAppleDelegates! = nil

@State是你告訴SwiftUI你的結構體內容是可變的可能發(fā)生更新的方式。所有的@State屬性必須放置一個值,這就是為什么在這兒會看到奇怪的nil

現(xiàn)在,在同一個文件中,在showAppleLogin()中將controller的創(chuàng)建替換為以下代碼:

// 1
appleSignInDelegates = SignInWithAppleDelegates() { success in
  if success {
    // update UI 
  } else {
    // show the user an error
  }
}

// 2
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = appleSignInDelegates

// 3
controller.performRequests()

解釋下發(fā)生了什么:

  1. 創(chuàng)建代理并賦值給該類的屬性。
  2. 和之前一樣創(chuàng)建ASAuthorizationController,但這次,我們使用自定義的代理類。
  3. 通過調用performRequests(),你講要求iOS來展示Sign In with Apple的模態(tài)界面。

你的代理的回調的地方就是最終用戶有沒有成功授權你的app所需展示不同界面的地方。

自動登錄

你已經實現(xiàn)了Sign In with Apple,但是用戶必須明確的點擊那個按鈕才行。如果你把他們帶入到登錄界面,你需要判斷他們是否已經通過Sign In with Apple。回到ContentView.swift,在.onAppear塊中添加這行:

self.performExistingAccountSetupFlows()

注意:SwiftUI中的.onAppear()和UIKit中的viewDidAppear(_:)完全一致。

當界面出現(xiàn)的時候,你希望iOS檢查和這個app相關的Apple ID和iCloud鑰匙串的憑證。如果他們存在,你講自動彈出Sign In with Apple對話框,這樣的話用戶就不必手動去點擊按鈕。因為手動點擊和自動調用使用的是同一套代碼,將showAppleLogin方法拆分為兩段代碼:

private func showAppleLogin() {
  let request = ASAuthorizationAppleIDProvider().createRequest()
  request.requestedScopes = [.fullName, .email]
  
  performSignIn(using: [request])
}

private func performSignIn(using requests: [ASAuthorizationRequest]) {
  appleSignInDelegates = SignInWithAppleDelegates() { success in
    if success {
      // update UI
    } else {
      // show the user an error
    }
  }

  let controller = ASAuthorizationController(authorizationRequests: requests)
  controller.delegate = appleSignInDelegates

  controller.performRequests()
}

在這里代碼并沒有變化,只是將代理的創(chuàng)建和展示放在單獨的代碼塊中。

現(xiàn)在,我們來實現(xiàn)performExistingAccountSetupFlows()

private func performExistingAccountSetupFlows() {
  // 1
  #if !targetEnvironment(simulator)

  // 2
  let requests = [
    ASAuthorizationAppleIDProvider().createRequest(),
    ASAuthorizationPasswordProvider().createRequest()
  ]

  // 2
  performSignIn(using: requests)
  #endif
}

這里有幾步操作:

  1. 如果你使用模擬器運行,那么什么都不做。如果你調用這個方法,模擬器會打印出錯誤。
  2. 要求蘋果去請求Apple ID和iCloud鑰匙串檢查。
  3. 調用你已有的創(chuàng)建代碼。

在第二步中,注意到你沒有明確你想要獲取的最終用戶的詳細信息?;貞浿暗闹改希阋呀泴W到它只會僅有一次提供詳細信息。由于這個流程是用來檢查已存在的賬戶,我們并沒有理由去請求requestedScopes屬性。事實上,如果你在這里設置了,它也會忽略掉!

服務器憑證

如果你的app有一個特定的網站,你可以更進一步處理網站的憑證。如果你去看一眼UserAndPassword.swift文件,你會看到一處是調用SharedWebCredential(domain:)方法,目前只是傳遞一個空的字符串給構造方法。將字符串替換為你的網站的域名。

現(xiàn)在,登錄你的網站并在網站的根目錄創(chuàng)建一個文件夾叫做.wellknow,在文件夾中創(chuàng)建一個文件叫做apple-app-site-association,并粘貼下面的JSON:

{
    "webcredentials": {
        "apps": [ "ABCDEFGHIJ.com.raywenderlich.SignInWithApple" ]
    }
}

注意:確定這個文件名是沒有擴展名的。

你需要把ABCDEFGHIJ替換為你蘋果開發(fā)者賬號的十個字符組成的Team ID。你可以在https://developer.apple.com/account網站的Membership欄下找到你的Team ID。你也需要將你使用的app都匹配上對應的bundle id。

通過這幾個步驟,你將app中的登錄詳細信息和Safari中的登錄的詳細信息鏈接起來?,F(xiàn)在在Safari上登錄你的網站也可以使用Sign in with Apple了。

當用戶手動使用用戶名和密碼時憑證會被保存,因此在下次需要使用時直接可用。

運行時檢查

在你的app生命周期的任何一個時刻,用戶都可以進入設置界面并將你的app的Sign In with Apple更改為不可用?;谟脩粼摬僮鞯膱?zhí)行,你想要檢查用戶是否還被允許登錄。蘋果推薦你運行這段代碼:

let provider = ASAuthorizationAppleIDProvider()
provider.getCredentialState(forUserID: "currentUserIdentifier") { state, error in
  switch state {
  case .authorized:
    // Credentials are valid.
    break
  case .revoked:
    // Credential revoked, log them out
    break
  case .notFound:
    // Credentials not found, show login UI
    break
  }
}

蘋果說明這個getCredentialState(forUserId:)的調用會非常的快。所以你需要在app被啟動的時候以及任何你需要確保用戶是否依然被授權的時候調用。而我建議你不要在app剛啟動時運行除非這個操作時必須的。使用你的app難道真的需要一個登錄的或者已注冊的用戶嗎?應該僅當用戶操作那些必須登錄后的操作時才執(zhí)行這段代碼。事實上,這也是Human Interface Guidelines里面推薦的那樣!

記住有許多用戶會卸載剛下載的app,因為這個app在第一次啟動時就要求他們進行注冊。

取而代之,我們應該監(jiān)聽蘋果提供的通知來知曉用戶是否登出。簡單的我們監(jiān)聽ASAuthorizationAppleIDProvider.credentialRevokedNotification通知并作出相應的操作。

The UIWindow

此時此刻,你已經完全實現(xiàn)了Sign In with Apple。祝賀!

如果你已經觀看了關于Sign In with Apple的WWDC演講或者閱讀了其他的指南,你可能會注意到我們這丟失了一段。你沒有實現(xiàn)ASAuthorizationControllerPresentationContextProviding代理方法來告訴iOS該使用哪個UIWindow。然而,技術上來說如果我們使用默認的UIWindow,我們什么都不要做,但是知道如何操作還是對我們有好處的。

如果你沒有使用SwiftUI,那么將window屬性從你的SceneDelegate總抓取出來并返回是相當簡單的。但是在SwiftUI中,這卻變得有些難度。

The Environment

SwiftUI中有一個新的概念叫做Environment,這是一你存儲給很多SwiftUI界面使用的數據的地方。在某種程度上,你可以把它認為是一種依賴注入。

Environment創(chuàng)建

看一眼EnvironmentWindowKey.swift,你會看到那些需要在SwiftUI中存儲值的代碼。因為你去定義一個鍵傳遞給@Environment屬性包裝起來并存儲該值是非常樣板化的。并且我們注意到,如果一個class類型要被存儲,它要被明確標記為weak引用來放置循環(huán)引用。

注意:這個environment代碼是由WWDC實驗室的一位蘋果工程師提供的。

改變ContentView

讓我們跳轉到ContentView.swift并在ContentView的頂部添加另一份屬性:

@Environment(\.window) var window: UIWindow?

iOS將會自動構建window屬性,在environment中獲取它的值。

performSignIn(using:)方法時,我們修改構造函數使其傳入window屬性:

appleSignInDelegates = SignInWithAppleDelegates(window: window) { success in

你也想要告訴ASAuthorizationController你想要用自己的類作為presentationContextProvider的代理,所以在設置controller.delegate后面緊跟這句代碼:

controller.presentationContextProvider = appleSignInDelegates
更新代理

打開SignInWithAppleDelegate.swift來更改處理新的屬性和構造函數。用下面的代碼替換掉類定義里的,而不是那些用來對注冊和代理方法擴展中的代碼:

class SignInWithAppleDelegates: NSObject {
  private let signInSucceeded: (Bool) -> Void
  // 1
  private weak var window: UIWindow!

  // 2
  init(window: UIWindow?, onSignedIn: @escaping (Bool) -> Void) {
    // 3
    self.window = window
    self.signInSucceeded = onSignedIn
  }
}

就幾步操作:

  1. 存儲一個新的window的weak引用。
  2. 在初始化函數中添加UIwindow參數作為第一個參數。
  3. 將傳遞進來的值存儲到屬性中。

最后,實現(xiàn)一個新的代理:

extension SignInWithAppleDelegates: 
    ASAuthorizationControllerPresentationContextProviding {
  func presentationAnchor(for controller: ASAuthorizationController) 
      -> ASPresentationAnchor {
    return self.window
  }
}

這個代理就只需要實現(xiàn)一個方法,目的就是返回一個期待的window,沒錯,就是用來展示Sign In with Apple對話框的window。

更新界面

UIWindow放入environment只差那么一小步。打開SceneDelegate.swift并將下面這行代碼:

window.rootViewController = UIHostingController(rootView: ContentView())

替換為這些代碼:

// 1
let rootView = ContentView().environment(\.window, window)

// 2
window.rootViewController = UIHostingController(rootView: rootView)

這里只做了兩小步:

  1. 你創(chuàng)建了ContentView并追加了恰當的window值。
  2. 你講rootView變量傳遞給UIHostingController代替的原來的初始化方法。

這個environment方法返回的some view基本上來說是它先拿了你的ContentView,將你傳入的值強行推入到那個view的environment中,然后再把這個ContentView返回給你。任何從ContentView中展現(xiàn)的SwiftUI的view現(xiàn)在將都會在持有environment中的值。

如果你在其他地方創(chuàng)建一個新的root view,那個root view就不會持有environment中的值,除非你也同樣明確的傳遞給它。

登錄無法滑動

對于sign In with Apple我們要記住的一個缺點是iOS所展示的界面無法滑動!對于大多數用戶來說他們并不在意,但記住這一點很重要。作為我的app使用的網站的擁有者,舉個例子,我有很多登錄賬號。不僅僅是app的登錄,還有SQL數據庫的登錄,PHP管理員的登錄,等等。

如果你有太多的登錄賬號,很有可能會是最終用戶看不到他們所需要的賬號。嘗試確保將你app鏈接的網站只有影響app登錄相關的賬號。而不要把你所有的app都捆綁到一個域名下。

接下來...

你可以點擊頂部或者底部的下載按鈕來下載整個項目文件。

目前為止SignWithAppleDelegates.swift返回的是一個布爾值的成功,但是你可能更像使用一些類似于Swift 5的Result類型,這樣的話,你不僅可以反回服務器給的數據,也可以返回自定義的錯誤類型表示失敗。請觀看我們的視頻教程,What's new in Swift 5: Types如果你對Result不熟的話。

我們希望你喜歡這份指南,如果你有任何問題和評論,請加入我們的論壇進行討論!

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • 前言筆者最近了解了iOS13 新增的功能之Sign In With Apple。會輸出2篇文章,給大家分享一下。這...
    Lucky_Man閱讀 3,035評論 0 3
  • 級別: ★☆☆☆☆標簽:「iOS 13」「雙重因子驗證」「Sign In With Apple」作者: WYW審...
    QiShare閱讀 5,927評論 3 22
  • 原創(chuàng): 前行哲 iOS知識分享 今天 通過本文,你將了解到是否需要集成 Sign in with Apple 功...
    Leeson1989閱讀 56,244評論 49 122
  • 木青同學閱讀 273評論 0 0
  • 我想你了,有很多話想和你聊,我想你對我每天都說的情話,想你對我的好,對我的關心,對我的愛……想起了你的魅力,想起了...
    d5905e026569閱讀 146評論 0 0

友情鏈接更多精彩內容