0%

sign with Apple开发总结

背景

要在App Store 新上架的App要在4月份前适配iOS 13,其中App如果使用了第三方登录的,就要同时支持AppleId 登录,也是一种第三方登录.

先放一个时序图:

第一步,客户端和AppleServer交互后请求后端

客户端向苹果服务器请求,拿到用户的信息和identityToken.主要返回数据如下:

  • user: 用户唯一ID,在一个开发者账号下的APP获取到的是一样的,类似微信开发API中的openid;
  • identityToken: 「JWT」格式的token,用于验证信息合法性。
  • email: 用户邮箱(可能为空)
  • fullName: 昵称等信息
  • realUserStatus: 是否是“真实用户”,可用于反作弊,对抗黑灰产. (0为黑户,1为不确定,2为正常用户)

拿到信息后调用接口,把信息传给后端,但这样有个重要的问题就是不能保证安全性,无法判断请求是否是伪造的。这个时候就要使用identityToken了。

注意:当第一次认证成功之后,将不会再返回email,fullName等信息,可以在设置->Apple ID->密码与安全性->使用您AppleID的App 中删除对应的APP。

第二步,后端校验identityToken合法性

identityTokenString实际上是JWT(JSON Web Token)格式的文件,JWT文件由三部分组成:

  • Header
  • Payload
  • Signature

这三部分由”.”分割,其中Header和Payload是经过base64编码的。
Header base64解码之后示例:

1
2
3
4
{
"kid":"eXaunmL", // 用来确定publicKey,后面说
"alg":"RS256" // 加密算法
}

Payload base64解码之后示例:

1
2
3
4
5
6
7
8
9
10
{
"iss":"https://appleid.apple.com",
"aud":"com.easeapi.www",
"exp":1584588402,
"iat":1584587802,
"sub":"022409.17avbbaf112941e5a722788e7f3880f4.4565", // 用户唯一ID
"c_hash":"DmuPZ_bX1Tr6AGFW3rDYbQ",
"auth_time":1584587802,
"nonce_supported":true
}

而Signature部分就是对Header及Payload两部分内容按指定算法进行签名,大致逻辑如下:

1
2
3
Signature = signature(base64UrlEncode(Header) + "." + base64UrlEncode(Payload), secretKey)
#signature代表具体的加密算法;
#secretKey为密钥;

具体到identityToken,Apple目前采用的是RS256的非对称加密算法:

Apple会使用私钥(也即为上面的secretKey)对Header及Payload加密,获取Signature;
将Header,Payload及Signature信息包装为JWT格式文件,即是identityToken;
那么,我们如何才能验证拿到的identityToken是否合法呢,这就要用到Apple提供的公钥了。公钥获取地址:https://appleid.apple.com/auth/keys
直接用GET请求就会得等一个数组Keys,也就是 JWK 列表。这也就意味着客户端向服务器提交的 identityToken 可能是用 keys 里面的特定某个 JWK 来进行加密的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"keys":[
{
"kty":"RSA",
"kid":"86D88Kf",
"use":"sig",
"alg":"RS256",
"n":"iGaLqP6y-SJCCBq5Hv6pGDbG_SQ11MNjH7rWHcCFYz4hGwHC4lcSurTlV8u3avoVNM8jXevG1Iu1SY11qInqUvjJur--hghr1b56OPJu6H1iKulSxGjEIyDP6c5BdE1uwprYyr4IO9th8fOwCPygjLFrh44XEGbDIFeImwvBAGOhmMB2AD1n1KviyNsH0bEB7phQtiLk-ILjv1bORSRl8AK677-1T8isGfHKXGZ_ZGtStDe7Lu0Ihp8zoUt59kx2o9uWpROkzF56ypresiIl4WprClRCjz8x6cPZXU2qNWhu71TQvUFwvIvbkE1oYaJMb0jcOTmBRZA2QuYw-zHLwQ",
"e":"AQAB"
},
{
"kty":"RSA",
"kid":"eXaunmL",
"use":"sig",
"alg":"RS256",
"n":"4dGQ7bQK8LgILOdLsYzfZjkEAoQeVC_aqyc8GC6RX7dq_KvRAQAWPvkam8VQv4GK5T4ogklEKEvj5ISBamdDNq1n52TpxQwI2EqxSk7I9fKPKhRt4F8-2yETlYvye-2s6NeWJim0KBtOVrk0gWvEDgd6WOqJl_yt5WBISvILNyVg1qAAM8JeX6dRPosahRVDjA52G2X-Tip84wqwyRpUlq2ybzcLh3zyhCitBOebiRWDQfG26EH9lTlJhll-p_Dg8vAXxJLIJ4SNLcqgFeZe4OfHLgdzMvxXZJnPp_VgmkcpUdRotazKZumj6dBPcXI_XID4Z4Z3OM1KrZPJNdUhxw",
"e":"AQAB"
}
]
}

接下来就需要我们确定当前的 identityToken 到底是使用哪个 JWK 来加密的,这样做可以避免批量生成证书,提升性能。找到keys数组中的kid和我们刚刚从Header中解析出来的kid相同的那个对象.那个就我们要用的publicKey.但是现在的publicKey格式不是我们要的PEM文件格式.所以要把数据再转一下格式.

1
2
3
4
5
6
7
// pem公钥
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8kGa1pSjbSYZVebtTRBLxBz5H
4i2p/llLCrEeQhta5kaQu/RnvuER4W8oDH3+3iuIYW4VQAzyqFpwuzjkDI+17t5t
0tyazyZ8JXw+KgXTxldMPEL95+qVhgXvwtihXC1c5oGbRlEDvDF6Sa53rcFVsYJ4
ehde/zUxo6UvS7UrBQIDAQAB
-----END PUBLIC KEY-----

我这里用的是jwk-to-pem包做的.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const jwkToPem = require('jwk-to-pem');
async getPublicKey(kid) {
let publicKey;
const result = await this.ctx.curl('https://appleid.apple.com/auth/keys', {
method: 'GET',
dataAsQueryString: true,
dataType: 'json',
});
for (const key of result.data.keys) {
if (key.kid === kid) {
publicKey = key;
break;
}
}
const pem = jwkToPem(publicKey);
return pem;
}

获取PEM后再用JWT校验identityToken就可以了

1
2
3
4
let header = identityToken.split('.')[0];
header = JSON.parse(Base64.decode(header));
const publicKey = await this.getPublicKey(header.kid);
const data = this.ctx.app.jwt.verify(identityToken, publicKey);

第三步,业务逻辑处理

使用user或者校验identityToken得到的sub作为唯一标识注册用户,这里最好还要缓存一下email,fullName字段,以免因为网络等原因在校验成功后没有注册用户,导致丢失数据.

参考文档:
大伟不是戴维 blog
segmentfault