# 1:背景

2019 年苹果推出 苹果登录(Sign in with Apple)方式,要求 2020 年 4 月之后运行在 iOS13 及以上系统的 APP 如果使用第三方或社交登录服务(如 Facebook、谷歌、 Twitter、Linkedln 或亚马逊等),必须向用户提供 “以苹果账号登录” 服务的选项。其中苹果的审核细则 4.8 也明确的规定了这一点。

<font color="#dd0000"> 不过需要注意的一点是腾讯系列的产品如果只是使用微信、QQ 登录并不算第三方登录,所以可以添加 AppleID 登录方式。</font><br />

# 2:前置配置

# 2.1 Xcode 工程配置

选中工程 trager,在 capabilities 中添加 AppleID 登录的能力

# 2.2 开发者账号配置

基于授权码的后端验证方式需要此步骤,如果使用 JWT 验证方式则不依赖此步骤,不过建议按顺序看完多做了解。

该步骤的最终目的是获取用于校验客户端身份的所需内容,其中包括以下三个内容

  • 生成一个用于校验客户端身份的密钥文件

  • 获取 KeyID

  • 获取 iss(TeamID)

<font color="#dd0000"> 注意:该步骤需要登录 Apple 开发者账号并对其进行功能的配置、开启、以及描述文件更新等操作,可能需要证书管理团队或者有相关权限的人员来处理,并由他们将对应信息输出 </font><br />

# 步骤一:能力开启

进入开发者账号,选择需要支持 AppleID 登录能力的应用并进入打开其 AppleID 登录的功能

# 步骤二:更新 profile

对 app 的任何更改都会导致现有的 profile 文件失效,所以需要重新生成 profile 描述文件。

按照如下路径操作,点进已经 invalid 的描述文件并重新生成

# 步骤三:生成密钥文件

进入如下界面点击 加号 进行生成

填完并勾选 Sigin with apple 后点击右侧的 Configure 进行配置,在配置页面选择需要开启苹果登录的 app 并保存,然后回到上一页并开始注册


最终注册成功后会有 KeyID、TeamID 和可供下载的密钥文件

密钥文件格式为.p8 实际是文本文件

<font color="#dd0000"> 注意:密钥文件只能被下载一次,下载后保存在安全的地方,丢了的话只能重新申请了 </font><br />

# 3:登录流程

登录流程分两大块,一个是客户端部分,一个是后端部分,其中后端部分有两种校验方式 基于授权码的后端验证基于JWT的算法验证 ,稍后会一一讲解。总体流程如下图:

# 3.1 客户端侧

# 步骤一:授权

对于客户端来说 AppleID 登录与传统的三方登录流程一样,分为 调用接口回调信息获取 两步,唯一不同点是苹果登录的 API 是在 iOS SDK 内部封装,只用导入对应头文件即可
#import <AuthenticationServices/AuthenticationServices.h>

关于登录入口,苹果对 AppleID 登录的 UI 有严格的限制,因此专门提供了提供了一套继承于 UIControl 等控件来供开发者使用 ASAuthorizationAppleIDButton

1
2
3
4
ASAuthorizationAppleIDButton * appleIDBtn = [ASAuthorizationAppleIDButton buttonWithType:ASAuthorizationAppleIDButtonTypeDefault style:ASAuthorizationAppleIDButtonStyleWhite];
appleIDBtn.frame = CGRectMake(0, 0, 100, 60);
[appleIDBtn addTarget:self action:@selector(didAppleIDBtnClicked) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:appleIDBtn];

其中按钮的 文案类型UI风格 可以通过枚举进行配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//  文案类型
typedef NS_ENUM(NSInteger, ASAuthorizationAppleIDButtonType) {
ASAuthorizationAppleIDButtonTypeSignIn,
ASAuthorizationAppleIDButtonTypeContinue,

ASAuthorizationAppleIDButtonTypeSignUp API_AVAILABLE(ios(13.2), macos(10.15.1), tvos(13.1)) API_UNAVAILABLE(watchos),

ASAuthorizationAppleIDButtonTypeDefault = ASAuthorizationAppleIDButtonTypeSignIn,
} NS_SWIFT_NAME(ASAuthorizationAppleIDButton.ButtonType) API_AVAILABLE(ios(13.0), macos(10.15), tvos(13.0)) API_UNAVAILABLE(watchos);

// UI风格
typedef NS_ENUM(NSInteger, ASAuthorizationAppleIDButtonStyle) {
ASAuthorizationAppleIDButtonStyleWhite,
ASAuthorizationAppleIDButtonStyleWhiteOutline,
ASAuthorizationAppleIDButtonStyleBlack,
} NS_SWIFT_NAME(ASAuthorizationAppleIDButton.Style) API_AVAILABLE(ios(13.0), macos(10.15), tvos(13.0)) API_UNAVAILABLE(watchos);

但是并不推荐这种方式使用,原因如下:

  • 1:固定 UI 无法满足业务的定制化需求
  • 2:文案固定,多语言配置需要在单独的地方去配置文案

所以建议自己写 UI,直接在点击事件中调用 AppleID 的相关 API 进行授权登陆操作,具体代码为,其中 ASAuthorizationAppleIDRequest 为是否使用 Keychain 信息,如果如果 KeyChain 里面也有登录信息的话,可以直接使用里面保存的用户名和密码进行登录。可以根据实际业务需求来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
KINFO(@"[AppleLoginWrapper]开始苹果登录鉴权");
if (@available(iOS 13.0, *)) {
ASAuthorizationAppleIDProvider *provider = [ASAuthorizationAppleIDProvider new];
ASAuthorizationAppleIDRequest *request = [provider createRequest];
request.requestedScopes = @[ ASAuthorizationScopeFullName, ASAuthorizationScopeEmail ]; //请求的用户信息
ASAuthorizationPasswordRequest * keychainRequest = [[[ASAuthorizationPasswordProvider alloc] init] createRequest];

ASAuthorizationController *vc = [[ASAuthorizationController alloc] initWithAuthorizationRequests:@[ request ,keychainRequest]];
vc.delegate = self;
vc.presentationContextProvider = self;
[vc performRequests];
} else {
// Fallback on earlier versions
KINFO(@"[AppleLoginWrapper]iOS系统低于13");
}

# 步骤二:信息回调

依赖的两个 delegate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma mark- 代理 ASAuthorizationControllerDelegate
- (void)authorizationController:(ASAuthorizationController *)controller didCompleteWithAuthorization:(ASAuthorization *)authorization {
// 成功
// 其中`authorization.credential`包含了Token,用户ID等授权所需信息,可上报到后台
}
- (void)authorizationController:(ASAuthorizationController *)controller didCompleteWithError:(NSError *)error {
// 失败
}

#pragma mark- 代理ASAuthorizationControllerPresentationContextProviding
- (ASPresentationAnchor)presentationAnchorForAuthorizationController:(ASAuthorizationController *)controller {
// 展示在哪个Window上
return self.view.window;
}

# 步骤三:用户 ID 状态校验

防止用户注销 AppleId 或 停止使用 Apple ID 的状态处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
if (@available(iOS 13.0, *)) {
// 注意 存储用户标识信息需要使用钥匙串来存储 这里使用NSUserDefaults 做的简单示例
NSString * userIdentifier = [[NSUserDefaults standardUserDefaults] valueForKey:@"appleID"];
if (userIdentifier) {
ASAuthorizationAppleIDProvider * appleIDProvider = [[ASAuthorizationAppleIDProvider alloc] init];
[appleIDProvider getCredentialStateForUserID:userIdentifier
completion:^(ASAuthorizationAppleIDProviderCredentialState credentialState, NSError * _Nullable error) {
switch (credentialState) {
case ASAuthorizationAppleIDProviderCredentialAuthorized:
// 授权状态有效
break;
case ASAuthorizationAppleIDProviderCredentialRevoked:
// 苹果账号登录的凭据已被移除,需解除绑定并重新引导用户使用苹果登录
break;
case ASAuthorizationAppleIDProviderCredentialNotFound:
// 未登录授权,直接弹出登录页面,引导用户登录
break;
case ASAuthorizationAppleIDProviderCredentialTransferred:
// 授权AppleID提供者凭据转移
break;
}
}];
}
}
return YES;
}

# 3.2 Sever 侧

基于上面流程图,Sever 侧校验 Token 有效性的方式有两种:

# 方式一:基于授权码的后端验证

后端在收到客户端传递的包含 token 的信息后进行验证

  • 构建 client_secret
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    -----BEGIN PRIVATE KEY-----
    BASE64编码后的密钥 (步骤2.2中获得)
    -----END PRIVATE KEY-----

    public byte[] readKey() throws Exception {
    String temp = "密钥文件中间的编码字符串";
    return Base64.decodeBase64(temp);
    }

    构建client_secret关键代码:

    String client_id = "..."; // 被授权的APP ID(步骤2.2中获得)
    Map<String, Object> header = new HashMap<String, Object>();
    header.put("kid", "密钥id"); // 参考后台配置(步骤2.2中获得)
    Map<String, Object> claims = new HashMap<String, Object>();
    claims.put("iss", "team id"); // 参考后台配置 team id(步骤2.2中获得)
    long now = System.currentTimeMillis() / 1000;
    claims.put("iat", now);
    claims.put("exp", now + 86400 * 30); // 最长半年,单位秒
    claims.put("aud", "https://appleid.apple.com"); // 默认值
    claims.put("sub", client_id);
    PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(readKey());
    KeyFactory keyFactory = KeyFactory.getInstance("EC");
    PrivateKey privateKey = keyFactory.generatePrivate(pkcs8EncodedKeySpec);
    String client_secret = Jwts.builder().setHeader(header).setClaims(claims).signWith(SignatureAlgorithm.ES256, privateKey).compact();
  • 验证客户端 Token
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    String url = "https://appleid.apple.com/auth/token";
    // POST 请求
    HttpSynClient client = new HttpSynClient(5000, 5000, 5000, 20);
    Map<String, String> form = new HashMap<String, String>();
    form.put("client_id", client_id);
    form.put("client_secret", client_secret);
    form.put("code", code);form.put("grant_type","authorization_code");
    form.put("redirect_uri", redirectUrl);
    HttpResponse result = client.excutePost(url, form);
    System.out.println(result);
  • 上述步骤结束后即可将结果回调给客户端,进行登录或者是错误处理
    • 成功示例
      1
      2
      3
      4
      5
      6
      7
      {
      "access_token":"a0996b16cfb674c0eb0d29194c880455b.0.nsww.5fi5MVC-i3AVNhddrNg7Qw",
      "token_type":"Bearer",
      "expires_in":3600,
      "refresh_token":"r9ee922f1c8b048208037f78cd7dfc91a.0.nsww.KlV2TeFlTr7YDdZ0KtvEQQ",
      "id_token":"eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnNreW1pbmcuYXBwbGVsb2dpbmRlbW8iLCJleHAiOjE1NjU2NjU1OTQsImlhdCI6MTU2NTY2NDk5NCwic3ViIjoiMDAwMjY2LmRiZTg2NWIwYWE3MjRlMWM4ODM5MDIwOWI5YzdkNjk1LjAyNTYiLCJhdF9oYXNoIjoiR0ZmODhlX1ptc0pqQ2VkZzJXem85ZyIsImF1dGhfdGltZSI6MTU2NTY2NDk2M30.J6XFWmbr0a1hkJszAKM2wevJF57yZt-MoyZNI9QF76dHfJvAmFO9_RP9-tz4pN4ua3BuSJpUbwzT2xFD_rBjsNWkU-ZhuSAONdAnCtK2Vbc2AYEH9n7lB2PnOE1mX5HwY-dI9dqS9AdU4S_CjzTGnvFqC9H5pt6LVoCF4N9dFfQnh2w7jQrjTic_JvbgJT5m7vLzRx-eRnlxQIifEsHDbudzi3yg7XC9OL9QBiTyHdCQvRdsyRLrewJT6QZmi6kEWrV9E21WPC6qJMsaIfGik44UgPOnNnjdxKPzxUAa-Lo1HAzvHcAX5i047T01ltqvHbtsJEZxAB6okmwco78JQA"
      }
    • 失败示例
      1
      2
      3
      {
      "error": "invalid_client"
      }
# 方式二:基于 JWT 验证原理
  • 获取苹果公钥,并保存

    用到公钥接口 https://appleid.apple.com/auth/keys
    返回值样例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {  
    "keys": [
    {
    "kty": "RSA",
    "kid": "AIDOPK1",
    "use": "sig",
    "alg": "RS256",
    "n": "lxrwmuYSAsTfn-lUu4goZSXBD9ackM9OJuwUVQHmbZo6GW4Fu_auUdN5zI7Y1dEDfgt7m7QXWbHuMD01HLnD4eRtY-RNwCWdjNfEaY_esUPY3OVMrNDI15Ns13xspWS3q-13kdGv9jHI28P87RvMpjz_JCpQ5IM44oSyRnYtVJO-320SB8E2Bw92pmrenbp67KRUzTEVfGU4-obP5RZ09OxvCr1io4KJvEOjDJuuoClF66AT72WymtoMdwzUmhINjR0XSqK6H0MdWsjw7ysyd_JhmqX5CAaT9Pgi0J8lU_pcl215oANqjy7Ob-VMhug9eGyxAWVfu_1u6QJKePlE-w",
    "e": "AQAB"
    }
    ]
    }

  • 验证客户端的 Token 有效性

    客户端会传以下几个值给服务端

    • userID:授权的用户唯一标识

    • email、fullName:授权的用户资料

    • authorizationCode:授权 code

    • identityToken:授权用户的 JWT 凭证
      示例 identityToken:授权用户的 JWT 凭证

      1
      2
      eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmZ1bi5BcHBsZUxvZ2luIiwiZXhwIjoxNTY4NzIxNzY5LCJpYXQiOjE1Njg3MjExNjksInN1YiI6IjAwMDU4MC4wODdjNTU0ZGNlMzU0NjZmYTg1YzVhNWQ1OTRkNTI4YS4wODAxIiwiY19oYXNoIjoiel9KY0RscFczQjJwN3ExR0Nna1JaUSIsImF1dGhfdGltZSI6MTU2ODcyMTE2OX0.WmSa4LzOzYsdwTqAJ_8mub4Ls3eyFkxZoGLoy-U7DatsTd_JEwAs3_OtV4ucmj6ENT3153iCpYY6vBxSQromOMcXsN74IrUQew24y_zflN2g4yU8ZVvBCbTrR_6p9f2fbeWjZiyNcbPCha0dv45E3vBjyHhmffWnk3vyndBBiwwuqod4pyCZ3UECf6Vu-o7dygKFpMHPS1ma60fEswY5d-_TJAFk1HaiOfFo0XbL6kwqAGvx8HnraIxyd0n8SbBVxV_KDxf15hdotUizJDW7N2XMdOGQpNFJim9SrEeBhn9741LWqkWCgkobcvYBZsrvnUW6jZ87SLi15rvIpq8_fw


      token 被解密后分为三个部分

    • header: 包括了 key id 与加密算法

    • payload:

      • iss: 签发机构,苹果
      • aud: 接收者,目标 app
      • exp:过期时间
      • iat: 签发时间
      • sub: 用户 id
      • c_hash: 一个哈希数列
      • auth_time: 签名时间
    • signature: 用于验证 JWT 的签名

  • Token 验证原理:

    因为 idnetityToken 使用非对称加密 RSASSA【RSA 签名算法】 和 ECDSA【椭圆曲线数据签名算法】,当验证签名的时候,利用公钥来解密 Singature,当解密内容与 base64UrlEncode (header) + “.” + base64UrlEncode (payload) 的内容完全一样的时候,表示验证通过。

  • 防止中间人攻击原理:

    该 token 是苹果利用私钥生成的一段 JWT,并给出公钥我们对 token 进行验证,由于中间人并没有苹果的私钥,所以它生成出来的 token 是没有办法利用苹果给出的公钥进行验证的,确保的 token 的安全性。

# 4 总结

目前使用的是基于授权码的后端验证方式,每次收到客户端登录请求后都会像苹果服务器发送 post 请求来验证,导致受网络影响较大。如果改成第一种方式后,除了获取公钥外不再依赖网络请求,可降低网络异常情况带来的损失。但是服务端要定期刷新公钥,防止公钥变化带来的验证失败

# 5 参考文档

https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple

https://developer.apple.com/cn/sign-in-with-apple/

https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Molier 微信支付

微信支付

Molier 支付宝

支付宝