iOS 探究 | 第九篇 Sign in with Apple 及撤销令牌(Revoke Tokens)详细探究

4,815 阅读14分钟

技术组已发布文章列表,有兴趣的同学可以翻翻前面的文章:

第一篇 | iOS 属性 @property 详细探究

第二篇 | iOS 深入理解 Block 使用及原理

第三篇 | iOS 类别 Category 和扩展 Extension 及关联对象详解

第四篇 | iOS 常用锁 NSLock ,@synchronized 等的底层实现详解

第五篇 | iOS 全面理解 Nullability

第六篇 | iOS Equality 详细探究

第七篇 | iOS 异常 (NSException) 和错误 (NSError) 处理详解

iOS 探究 | 第八篇 Runtime 详细探究

引言

什么是“通过 Apple 登录”功能?

“通过 Apple 登录”功能让我们能够使用现有的 Apple ID 登录第三方 App 和网站,不仅快捷轻松,而且更加私密。


  • 关于“通过 Apple 登录”功能

如果我们在 App 或网站上看到“通过 Apple 登录”按钮,则说明可以使用自己的 Apple ID 来设置一个帐户。不需要使用社交媒体帐户,也不必填写表单或另外设定新密码。

“通过 Apple 登录”功能的设计初衷就是为了尊重个人隐私,让我们能够掌控自己的个人信息。这个功能可以在 iOS、macOS、Apple tvOS 和 watchOS 上以及任何浏览器中作为原生功能使用。


  • 隐私和安全性

  • 在我们首次登录时,App 和网站只能要求获取我们的姓名和电子邮件地址来设置帐户。

  • 我们可以使用“隐藏邮件地址”(Apple 的私密电子邮件中转服务)来创建一个唯一的随机电子邮件地址,并借用这个地址将邮件转发到我们的个人电子邮件地址。这样一来,不用共享自己的个人电子邮件地址,就可以收到由 App 发送的有用邮件。

  • 在使用 App 和网站时,“通过 Apple 登录”功能既不会跟踪也不会分析我们的行为特征。Apple 只会保留必要的信息,以确保能够登录和管理自己的帐户。

  • “通过 Apple 登录”功能内建了具有双重认证的安全保护机制。如果我们在使用 Apple 设备,则可以随时通过面容 ID 或触控 ID 进行登录和重新认证。


  • 具体操作流程

  • 开发证书及权限设置:

  1. 进入开发者后台,选择 Certificates, Identifiers & Profiles -> Identifiers,找到要添加 Apple 登录的 Identifier,单击进入编辑页面,勾选 Sign In With Apple:

点击 Edit 进入 Apple 登录编辑页,选择 Enable as a primary App ID:

  1. 创建用于生成 client_secret 的私钥(后面会介绍如何生成 client_secret 及如何使用):

回到 Certificates, Identifiers & Profiles 页面,选择 Keys:

点击添加 Keys:

勾选 Sign in with Apple,点击 Configure (上图由于已勾选过,因此变成了 Edit)进入 Configure 编辑页:

选择要添加 Apple 登录的 App ID,并保存。

回到 Register a New Key 页面,点击 Continue

点击 Register,进入下载页面,下载保存私钥(妥善保存):

  1. 项目工程中开启 Sign In With Apple

打开工程,选中项目的 Target 添加开启 Sign In With Apple 功能:


iOS 端具体实现

  1. 添加登录按钮(具体实例可参考:Implementing User Authentication with Sign in with Apple

在示例应用程序中,LoginViewController 在其视图层次结构中显示一个登录表单和一个使用 Apple 登录按钮 (ASAuthorizationAppleIDButton)。视图控制器还将自己添加为按钮的目标,并在按钮接收到触摸事件时传递要调用的操作。

func setupProviderLoginView() {
    let authorizationButton = ASAuthorizationAppleIDButton()
    authorizationButton.addTarget(self, action: #selector(handleAuthorizationAppleIDButtonPress), for: .touchUpInside)
    self.loginProviderStackView.addArrangedSubview(authorizationButton)
}

注意: 将 Sign in with Apple 按钮添加到 Storyboard 时,还必须在 Xcode 的 Identity Inspector 中将控件的类值设置为 ASAuthorizationAppleIDButton

  1. 使用 Apple ID 请求授权:

当用户点击 Sign in with Apple 按钮时,视图控制器调用 handleAuthorizationAppleIDButtonPress() 函数,该函数通过对用户的全名和电子邮件地址执行授权请求来启动身份验证流程。 然后系统会检查用户是否在设备上使用他们的 Apple ID 登录。 如果用户未在系统级别登录,应用程序会弹出提示,告诉用户在“设置”中使用其 Apple ID 登录。

@objc
func handleAuthorizationAppleIDButtonPress() {
    let appleIDProvider = ASAuthorizationAppleIDProvider()
    let request = appleIDProvider.createRequest()
    request.requestedScopes = [.fullName, .email]
    
    let authorizationController = ASAuthorizationController(authorizationRequests: [request])
    authorizationController.delegate = self
    authorizationController.presentationContextProvider = self
    authorizationController.performRequests()
}

注意: 用户必须启用双重身份验证才能使用通过 Apple 登录,这样才能安全地访问帐户。

授权控制器调用 presentationAnchorForAuthorizationController: 函数从应用程序中获取窗口,在该窗口中,它在模式表中向用户呈现 Sign in with Apple 内容。

func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
    return self.view.window!
}

如果用户使用 Apple ID 在系统级别登录,则会出现描述“使用 Apple 登录”功能的列表,允许用户编辑其帐户中的信息。用户可以编辑他们的名字和姓氏,选择另一个电子邮件地址作为他们的联系方式,并在应用程序中隐藏他们的电子邮件地址。如果用户选择在应用程序中隐藏他们的电子邮件地址,Apple 会生成一个代理电子邮件地址,以将其电子邮件转发到用户的私人电子邮件地址。 最后,用户输入 Apple ID 的密码,然后单击继续创建帐户。

  1. 处理用户凭证:

如果身份验证成功,授权控制器调用 authenticationController:didCompleteWithAuthorization: 委托函数,应用程序使用该函数将用户数据存储在钥匙串中。

func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
    switch authorization.credential {
    case let appleIDCredential as ASAuthorizationAppleIDCredential:
        
        // Create an account in your system.
        let userIdentifier = appleIDCredential.user
        let fullName = appleIDCredential.fullName
        let email = appleIDCredential.email
        
        // For the purpose of this demo app, store the `userIdentifier` in the keychain.
        self.saveUserInKeychain(userIdentifier)
        
        // For the purpose of this demo app, show the Apple ID credential information in the `ResultViewController`.
        self.showResultViewController(userIdentifier: userIdentifier, fullName: fullName, email: email)
    
    case let passwordCredential as ASPasswordCredential:
    
        // Sign in using an existing iCloud Keychain credential.
        let username = passwordCredential.user
        let password = passwordCredential.password
        
        // For the purpose of this demo app, show the password credential as an alert.
        DispatchQueue.main.async {
            self.showPasswordCredentialAlert(username: username, password: password)
        }
        
    default:
        break
    }
}
  • User ID: Unique, stable, team-scoped user ID,苹果用户唯一标识符,该值在同一个开发者账号下的所有 App 下是一样的,开发者可以用该唯一标识符与自己后台系统的账号体系绑定起来。

  • Verification data: Identity token, code,验证数据,用于传给开发者后台服务器,然后开发者服务器再向苹果的身份验证服务端验证本次授权登录请求数据的有效性和真实性,详见 Sign in with Apple REST API。如果验证成功,可以根据 userIdentifier 判断账号是否已存在,若存在,则返回自己账号系统的登录态,若不存在,则创建一个新的账号,并返回对应的登录态给 App。

  • Account information: Name, verified email,苹果用户信息,包括全名、邮箱等。

  • Real user indicator: High confidence indicator that likely real user,用于判断当前登录的苹果账号是否是一个真实用户,取值有:unsupported、unknown、likelyReal。

注意: 在代码实现中,ASAuthorizationControllerDelegate.authorizationController(controller:didCompleteWithAuthorization:) 委托函数应该使用用户标识符中包含的数据在我们的系统中创建一个帐户。

如果认证失败,授权控制器调用 authorizationController:didCompleteWithError: 委托函数来处理错误。

func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
    // Handle error.
}

一旦系统对用户进行身份验证,应用程序就会显示 ResultViewController,它显示从框架请求的用户信息,包括用户提供的全名和电子邮件地址。视图控制器还显示一个退出按钮并将用户数据存储在钥匙串中。当用户点击退出按钮时,应用程序会从视图控制器和钥匙串中删除用户信息,并将 LoginViewController 呈现给用户。

  1. 请求现有凭证 LoginViewController.performExistingAccountSetupFlows() 函数通过请求 Apple ID 和 iCloud 钥匙串密码来检查用户是否有现有帐户。 与 handleAuthorizationAppleIDButtonPress() 类似,授权控制器设置其表示内容提供者并委托给 LoginViewController 对象。
func performExistingAccountSetupFlows() {
    // Prepare requests for both Apple ID and password providers.
    let requests = [ASAuthorizationAppleIDProvider().createRequest(),                    ASAuthorizationPasswordProvider().createRequest()]
    
    // Create an authorization controller with the given requests.
    let authorizationController = ASAuthorizationController(authorizationRequests: requests)
    authorizationController.delegate = self
    authorizationController.presentationContextProvider = self
    authorizationController.performRequests()
}

authorizationController(controller:didCompleteWithAuthorization:) 委托函数检查凭据是 Apple ID (ASAuthorizationAppleIDCredential) 还是密码凭据 (ASPasswordCredential)。如果凭证是密码凭证,系统会显示一条警报,允许用户使用现有帐户进行身份验证。

  1. 在启动时检查用户凭据

当开发者的 App 通过苹果账号登录后,iOS/macOS 设备上登录的 Apple ID 发生变化时,例如:

  • 设备上的 Apple ID 退出登录、切换新的账号登录;
  • 用户在设置页面禁止 App 使用苹果账号登录

此时,也需要通知到 App 做账号登出处理。因此,我们可以 App 启动时,调用 ASAuthorizationAppleIDProvider 的 getCredentialState 方法,传入当前用户的 UserIdentifier 进行判断:

示例应用仅在必要时显示使用 Apple 登录用户界面。 应用程序委托在 AppDelegate.application(_:didFinishLaunchingWithOptions:) 函数中启动后立即检查保存的用户凭据的状态。

getCredentialStateForUserID:completion: 函数检索保存在钥匙串中的用户标识符的状态。如果用户授予应用程序授权(例如,用户在设备上使用其 Apple ID 登录应用程序),则应用程序将继续执行。如果用户撤销了对应用程序的授权,或者未找到用户的凭据状态,应用程序将通过调用 showLoginViewController() 函数显示登录表单。

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    let appleIDProvider = ASAuthorizationAppleIDProvider()
    appleIDProvider.getCredentialState(forUserID: KeychainItem.currentUserIdentifier) { (credentialState, error) in
        switch credentialState {
        case .authorized:
            break // The Apple ID credential is valid.
        case .revoked, .notFound:
            // The Apple ID credential is either revoked or was not found, so show the sign-in UI.
            DispatchQueue.main.async {
                self.window?.rootViewController?.showLoginViewController()
            }
        default:
            break
        }
    }
    return true
}

苹果称,该 API 的速度非常快,我们可以在 App 每次启动时调用,然后根据结果做相应的处理:

  • authorized:登录状态有效;
  • revoked:上次使用苹果账号登录的凭据已被移除,需退出解除绑定并重新引导使用苹果登录;
  • notFound:未登录,直接显示开发者 App 的登录页面。

此外,苹果也提供了通知的方式来监听,在 App 使用过程中,当苹果账号发生变化时做相应的处理:

[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(receivedAppleIDCredentialRevokedNotification:) name:ASAuthorizationAppleIDProviderCredentialRevokedNotification object:nil];

- (void)receivedAppleIDCredentialRevokedNotification:(NSNotification *)notification {
    // oops
}


后端实现

  1. 客户端与 Server 端处理流程如下:

Sign_in_with_Apple.png

Server 端提供一个接口接收 identityTokenauthorizationCodeuserID 并进行有效性验证,具体参数意义,上文已介绍过。

  1. 校验身份(Verify the Identity Token): 首先将身份令牌和授权代码安全地传输到我们的 Server 端,有 Server 端调用苹果 API 去校验。有关验证用户身份所需信息的更多信息可参考:Generate and validate tokens。如果验证成功,可以根据 userIdentifier 判断账号是否已存在。如果存在,则返回账号相关信息,若不存在,则创建一个新的账号,并返回相应的信息给客户端。

要验证身份令牌,Server 端必须要验证如下:

  • 使用服务器的公钥验证 JWS E256 签名

  • 验证 nonce 以进行身份验证

  • 验证 iss 字段是否包含 appleid.apple.com

  • 验证 aud 字段是开发者的 client_id 即 bundle id

  • 验证时间是否早于 token 的 exp 值

关于生成苹果服务器验证合法性所需 JWT 格式的 client_secret 所需参数: private key:苹果开发者账号中创建,只能下载一次(.P8格式)需要妥善保管(上文已提到)

  • alg: 用于签署令牌的算法。对于使用 Apple 登录,需要使用 ES256。

  • kid: 与开发者帐户关联的 Sign in with Apple 私钥生成的 10 个字符的密钥标识符,即开发者账号中创建的私钥对应的 Key ID。

JWT payload 包含特定于 Sign in with Apple REST API 和客户端应用程序的信息,例如颁发者、主题和到期时间。在 payload 中使用以下参数:

  • iss: 发布者注册的标识 client secret 的主体。由于 client secret 属于开发人员团队,因此请使用与开发帐户关联的 10 个字符的 Team ID。

  • iat: 在注册时声明表示生成 client secret 的时间,以自 Epoch 以来的秒数表示,以 UTC 秒表示。

  • exp: 已注册的过期时间标识 client secret 过期的时间或者说是之后多久过期。与服务器上的当前 UNIX 时间相比,该值不得大于 15777000(6 个月,以秒为单位)。

  • aud: 声明标识 client secret 的预期接收者。由于 client secret 被发送到验证服务器,因此请使用 appleid.apple.com 固定值。

  • sub: 声明标识作为 client secret 主体。由于 client secret 只适用于当前的应用程序,因此请使用与 client_id 相同的值,即 bundle id。该值区分大小写。

创建 JWT 后,使用椭圆曲线数字签名算法 (ECDSA) 和 P-256 曲线和 SHA-256 哈希算法对其进行签名。解码的 client_secret JWT 令牌具有以下格式:

{
    "alg": "ES256",
    "kid": "AB2CDE588F"
}
{
    "iss": "EFGH6X6XYZ",
    "iat": 1437179036,
    "exp": 15777000,
    "aud": "https://appleid.apple.com",
    "sub": "com.minhechen.test"
}

无论在使用 Apple REST API 登录时使用哪种编程语言,都有各种在线开源库可用于创建和签署 JWT 令牌。有关更多信息可查看JWT.io

  1. 苹果后台验证 Token 的接口: Server 端通过调用 appleid.apple.com/auth/token 接口返回的数据和客户端通过接口传过来的数据进行校验,接口详细使用,可参考官方文档 Generate and Validate Tokens

  2. 验证通过后,查看账户绑定状态,如果已绑定则返回用户相关信息,如果未绑定则进行绑定相关操作。


关于解绑

苹果于2022 年 5 月 24 日发布了一则声明,要求支持帐户创建的 App 必须同时允许用户在 App 中发起帐户删除:

revoke_description.png

从技术的角度简单来说,最主要的就是两件事:

  1. App 要支持帐户删除。
  2. Apple 登录的帐户需要调用 REST API 来撤销用户令牌。

帐户删除这里就不多说了,现在市面上的 App 基本上都已支持该功能。 这里重点说一下调用 REST API 来撤销用户令牌。撤销用户令牌的接口为:appleid.apple.com/auth/revoke 官方文档:Revoke Tokens

revoke_api.png

接口所需参数,上文已经介绍过,这里不再赘述,重点介绍一下 client_secret 的生成,这个参数通常都是 Server 端生成,但客户端也可以生成(弊端就很明显了,如:private key 端里写死且不安全,且无法更新等等,这里暂不讨论),下面重点介绍一下端内如何生成。

先准备好 Header 及 Payload:

extension Data {

    func urlSafeBase64EncodedString() -> String {

        return base64EncodedString()

            .replacingOccurrences(of: "+", with: "-")

            .replacingOccurrences(of: "/", with: "_")

            .replacingOccurrences(of: "=", with: "")

    }

}

struct Header: Encodable {
    let alg = "ES256"
    let kid = "AB2CDE588F"
}

struct Payload: Encodable {
    let iss = "EFGH6X6XYZ"
    let aud = "https://appleid.apple.com"
    let exp = "15777000"
    let iat = Date().timeIntervalSince1970
    let sub = "com.minhechen.test"
}

class func getJWTClientSecret() -> String {
    let secret = "-----BEGIN PRIVATE KEY-----\nMafsaaADFADGSM49AwEHBHkwqu22q\nllUYYiBl6j0DAQehRANSFTnJvB6qBR\nHe5CSAFDmNPGX+k1adssdFSwTBADny9zpsQg6HY2BCVsfsL\nKn/nesacK\n-----END PRIVATE KEY-----"
    let privateKey = SymmetricKey(data: Data(secret.utf8))
    let headerJSONData = try! JSONEncoder().encode(Header())
    let headerBase64String = headerJSONData.urlSafeBase64EncodedString()

    let payloadJSONData = try! JSONEncoder().encode(Payload())
    let payloadBase64String = payloadJSONData.urlSafeBase64EncodedString()

    let toSign = Data((headerBase64String + "." + payloadBase64String).utf8)

    let signature = HMAC<SHA256>.authenticationCode(for: toSign, using: privateKey)
    let signatureBase64String = Data(signature).urlSafeBase64EncodedString()

    let token = [headerBase64String, payloadBase64String, signatureBase64String].joined(separator: ".")

    return token
}

注意: 私钥中,除了私钥的开头“-----BEGIN PRIVATE KEY-----”和结尾“-----END PRIVATE KEY-----”单独一行,其他每一行需要每64个字符换一行,上面代码中的 “\n” 即为换行符。

正常来说,调用 auth/revoke 接口会返回状态码:200,就意味着 App 登录撤销令牌成功,之后就可以继续其他业务流程了。至此 Revoke tokens 功能就实现了。


总结

最近恰巧在项目中做了 Revoke tokens 功能,需求几经周折,最终确定的方案还是由客户端调用 Server 端接口进行令牌撤销。实际开发中也会涉及到法务及隐式协议相关的注意事项,每个产品可根据各自的特点进行灵活调整。总之按照苹果巴巴说的做就行了,不配合的最后都得叫巴巴。以上就是本文关于 Sign in with apple 的完整介绍,希望这篇文章对你有所帮助,感谢阅读。

参考资料:

Implementing User Authentication with Sign in with Apple

Generate and Validate Tokens

Verifying a User

Revoke Tokens


关于技术组

iOS 技术组主要用来学习、分享日常开发中使用到的技术,一起保持学习,保持进步。文章仓库在这里:github.com/minhechen/i… 微信公众号:iOS技术组,欢迎联系进群学习交流,感谢阅读。