apple帐号登录服务器端接入

最近有新产品要提交App Store,发现APP审核增加了接入apple帐号登录的要求。所以,借此机会研究下apple帐号登录,做一个分享。

Sign In With Apple

这是苹果推出一套标准接口,用户通过端上的Apple ID登录第三方APP。官方网址 https://developer.apple.com/documentation/sign_in_with_apple

时序图如下:

接入过程分两步:
1、客户端拉起用户登陆,获取授权码及用户信息
2、服务端验证

客户端拉起apple授权页面,等用户授权登录后,可以取到用户的 authorizationCode 、 identityToken 、user 等。

客户端的请求结果如下:

#pragma mark - ASAuthorizationControllerDelegate
- (void)authorizationController:(ASAuthorizationController *)controller didCompleteWithAuthorization:(ASAuthorization *)authorization API_AVAILABLE(ios(13.0))
{
    if ([authorization.credential isKindOfClass:[ASAuthorizationAppleIDCredential class]])       {
        ASAuthorizationAppleIDCredential *credential = authorization.credential;
        NSString *state = credential.state;
        NSString *user = credential.user;
        NSPersonNameComponents *fullName = credential.fullName;
        NSString *email = credential.email;
        NSString *authorizationCode = [[NSString alloc] initWithData:credential.authorizationCode encoding:NSUTF8StringEncoding];
        NSString *identityToken = [[NSString alloc] initWithData:credential.identityToken encoding:NSUTF8StringEncoding];
        ASUserDetectionStatus realUserStatus = credential.realUserStatus;
        
        NSLog(@"user: %@", user);
        NSLog(@"fullName: %@", fullName);
        NSLog(@"email: %@", email);
        NSLog(@"authorizationCode: %@", authorizationCode);
        NSLog(@"identityToken: %@", identityToken);
        NSLog(@"realUserStatus: %@", @(realUserStatus));
    }
}

这些信息具体含义和用途:
∙ 用户ID: user,苹果用户唯一标识,该值在同一个开发者账号下的所有 App 下是一样的。
∙ 验证数据: identityToken , authorizationCode ,用于服务端验证授权请求的合法性。
∙ 用户信息: fullName , email,包括全名、邮箱等。
∙ 真实用户标志: realUserStatus ,用于判断当前账号是否是一个真实用户,取值有:unsupported、unknown、likelyReal。

其实,根据 identityToken ,解析后可以得到用户信息。但是,数据从客户端取到,然后转发给服务端,这个过程中,数据存在被修改的可能性。

有两种验证用户的方法:
1、 解析出用户数据,再利用 Apple 公钥验证 identityToken
2、 根据 authorizationCode 从 Apple ID server 重新拿到 identityToken, 再解析出用户数据
当然,方法2拿到的 identityToken 也可以用公钥验证

解析验证 identity Token

从网上找了一个苹果服务器返回的 identityToken ,数据如下:

eyJraWQiOiI4NkQ4OEtmIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmNoYW5nZGFvLnR0c2Nob29sIiwiZXhwIjoxNTg5MTg1Mjg1LCJpYXQiOjE1ODkxODQ2ODUsInN1YiI6IjAwMTk0MC43YTExNDFhYTAwMWM0NjllYTE1NjNjNmJhZTk5YzM3ZC4wMzA3IiwiY19oYXNoIjoiN1gzc2x2dHVBU0kwYmFSbU0wVGFrQSIsImVtYWlsIjoiYXEzMmsydnpjd0Bwcml2YXRlcmVsYXkuYXBwbGVpZC5jb20iLCJlbWFpbF92ZXJpZmllZCI6InRydWUiLCJpc19wcml2YXRlX2VtYWlsIjoidHJ1ZSIsImF1dGhfdGltZSI6MTU4OTE4NDY4NSwibm9uY2Vfc3VwcG9ydGVkIjp0cnVlfQ.S9wCOt6EeOoRrSMq4kUkPgJPyP1ruMXEcEZeeQEd1CDpcyVWLI8nTOqrl-l0sWYR-5nl2-1iJyiu77fRv8T7dBoV0EHT7GgM1l7qhnWsI9I8V-56rA9ArdJrLIBJbxu7j-xzQhZb6PZ5MSxPZ6WqZay0RpP9JiQ23ybssWQsMnqzvVZkye0iNtBGT1LnfT80XNxmj8L2uJZY08mXjjWWsYY_h0_IRvqOLyaW99w-F8T9KuDkWz2Z-DJX_tiKC0DOT03ypBv82H0v_v-8lFlp4rNRSB82CdgfYwEWElU7zKZfaHJOxT3wOvRXNpbj6_hENPdbtG2ozgdg2oVEiamz0g

这是一个 JWT 数据串,先说下公钥验证的方法。

首先,要取到苹果的公钥,公钥目前是以 JWK 的形式对外发布,需要自己转成pem,获取的URL为 https://appleid.apple.com/auth/keys,数据如下:

{
  "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"
    }
  ]
}

实际上,这里是两个JWK数据,我们需要转成对应的证书pem文件。
字段的含义如下:

alg The encryption algorithm used to encrypt the token.
e The exponent value for the RSA public key.
kid A 10-character identifier key, obtained from your developer account.
kty The key type parameter setting. This must be set to “RSA”.
n The modulus value for the RSA public key.
use The intended use for the public key.

其中,e、n以及kid使用了大端字节序表示,再通过base64url编码

这里,我封装了JWK转pem的方法(目前只支持 RSA公钥,以后看需要拓展。)
RSA公钥需要 n、e字段,接口如下:

local jwt = require "jwt"
local ok, pem = jwt.jwk_to_pem(modulus, exponent)
print(">> jwt.jwk_to_pem(modulus, exponent)")
print(pem)

执行结果如下:

>> jwt.jwk_to_pem(modulus, exponent)
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiGaLqP6y+SJCCBq5Hv6p
GDbG/SQ11MNjH7rWHcCFYz4hGwHC4lcSurTlV8u3avoVNM8jXevG1Iu1SY11qInq
UvjJur++hghr1b56OPJu6H1iKulSxGjEIyDP6c5BdE1uwprYyr4IO9th8fOwCPyg
jLFrh44XEGbDIFeImwvBAGOhmMB2AD1n1KviyNsH0bEB7phQtiLk+ILjv1bORSRl
8AK677+1T8isGfHKXGZ/ZGtStDe7Lu0Ihp8zoUt59kx2o9uWpROkzF56ypresiIl
4WprClRCjz8x6cPZXU2qNWhu71TQvUFwvIvbkE1oYaJMb0jcOTmBRZA2QuYw+zHL
wQIDAQAB
-----END PUBLIC KEY-----

然后,利用公钥 pem 验证 identityToken(利用第三方库libjwt实现),方法如下

local jwt = require "jwt"
local ok, token_str = jwt.jwt_decode(jwt_str, pem)
print(">> jwt.jwt_decode(jwt_str, pem)")
print(token_str)

执行结果如下:

>> jwt.jwt_decode(jwt_str, pem)
{"alg":"RS256","kid":"86D88Kf","typ":"JWT"}.{"aud":"com.xxx.xxx","auth_time":1589184685,"c_hash":"7X3slvtuASI0baRmM0TakA","email":"aq32k2vzcw@privaterelay.appleid.com","email_verified":"true","exp":1589185285,"iat":1589184685,"is_private_email":"true","iss":"https://appleid.apple.com","nonce_supported":true,"sub":"001940.7a1141aa001c469ea1563c6bae99c37d.0307"}

如果不想公钥验证,直接解析 identityToken的方法如下

local jwt = require "jwt"
local ok, token_str = jwt.jwt_decode(jwt_str)
print(">> jwt.jwt_decode(jwt_str)")
print(token_str)

执行结果如下:

>> jwt.jwt_decode(jwt_str)
{"alg":"none","kid":"86D88Kf"}.{"aud":"com.xxx.xxx","auth_time":1589184685,"c_hash":"7X3slvtuASI0baRmM0TakA","email":"aq32k2vzcw@privaterelay.appleid.com","email_verified":"true","exp":1589185285,"iat":1589184685,"is_private_email":"true","iss":"https://appleid.apple.com","nonce_supported":true,"sub":"001940.7a1141aa001c469ea1563c6bae99c37d.0307"}

说下这几个字段:

{
  "alg":"none",
  "kid":"86D88Kf"
}
{
 "aud":"com.xxx.xxx",
 "auth_time":1589184685,
 "c_hash":"7X3slvtuASI0baRmM0TakA",
 "email":"aq32k2vzcw@privaterelay.appleid.com",
 "email_verified":"true",
 "exp":1589185285,
 "iat":1589184685,
 "is_private_email":"true",
 "iss":"https://appleid.apple.com",
 "nonce_supported":true,
 "sub":"001940.7a1141aa001c469ea1563c6bae99c37d.0307"
}

aud为app id,也就是请求的client_id, sub为用户在该app下的唯一id, iat为token的创建时间,exp为token的有效期,email为邮箱地址,其他字段的含义可以看这里

另外,说下c_hash, at_hash, s_hash:
c_hash : code 的hash值
at_hash : access_token 的hash值
s_hash : state 的hash值
我没有做过验证,在apple官网没有找到答案。网上找到OAuth 2.0开放协议对这块有说明 https://openid.net/specs/openid-connect-core-1_0.html#TokenSubstitution

$code = "c37906543364e6b";
$hash_var = hash("sha256", $code);
$first_16_bytes = substr($hash_var, 0, 16);
$b64_var = base64_encode($first_16_bytes);
echo rtrim(strtr($b64_var, '+/', '-_'), '=');

服务端获取 identity Token

利用 authorizationCode 获取 identityToken 的方法:(官方文档 https//developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens)
验证的过程是HTTPS验证,API如下:
POST https://appleid.apple.com/auth/token
请求字段说明:
client_id:这个是app id
client_secret: 密钥信息,使用 JWT编码,详见后文
code: 授权码,传客户端取到的 authorizationCode
grant_type: 此时固定写 authorization_code
其他非必要字段,就不做说明。

其中,需要特别讲下 client_secret,是一个json数据,例子如下:

{
    "alg": "ES256",
    "kid": "ABC123DEFG"
}
{
    "iss": "DEF123GHIJ",
    "iat": 1437179036,
    "exp": 1493298100,
    "aud": "https://appleid.apple.com",
    "sub": "com.mytest.app"
}

其中,iss为team id,iat为当前时间戳,exp为失效时间戳,aud固定,sub为app包名; kid为开发者帐号后台申请private key时系统附带生成的key id,猜测是apple用以确定解密对应的public key
这个字段需要使用 JWT 编码,转成 JWT数据串。
这里,我封装了jwt,如下:

>> jwt.jwt_encode(header, payload, pri_pem, 'es256')
eyJhbGciOiJFUzI1NiIsImtpZCI6ImFiY2RlZmciLCJ0eXAiOiJKV1QifQ.eyJhZG1pbiI6dHJ1ZSwiaWF0IjoxNTE2MjM5MDIyLCJuYW1lIjoiSm9obiBEb2UiLCJzdWIiOiIxMjM0NTY3ODkwIn0.QDzbIkVpLZ1Uf6OwnrabKnz9xH3WJ_nLoiUZlT37IiVu3aXEMCfZkE3LlDUo14JUE6iBHo1B_jG91zwZOz7oZA

JWT是一个基于JSON结构化的编码标准(RFC 7519),它定义了一种紧凑的、自包含的方式,可在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。(jwt.io可获取实现算法)
JWT由三部分组成,之间用圆点(.)连接。这三部分分别是:Header.Payload.Signature

Header 头部域,使用Base64编码,会消除等号 {
“alg”: “HS256”,
“typ”: “JWT”
}
Payload 数据域,使用 Base64编码,会消除等号 {
“sub”: “1234567890”,
“name”: “John Doe”,
“iat”: 1516239022
}
Signature 签名,对Header.Payload进行签名,支持多种算法

优点:支持多种签名算法,可兼顾安全性和效率,避免数据伪造
缺点:数据都是使用base64_encode简单编码,不能传输敏感信息。

需要注意的是,验证接口 /auth/token 返回的数据,字段如下:

access_token (Reserved for future use) A token used to access allowed data. Currently, no data set has been defined for access.
expires_in The amount of time, in seconds, before the access token expires.
id_token A JSON Web Token that contains the user’s identity information.
refresh_token The refresh token used to regenerate new access tokens. Store this token securely on your server.
token_type The type of access token. It will always be bearer.

关键字段 id_token,就是前面提到的 identity Token, 即用户信息,同样使用jwt编码,参照上文解开。

最后语

文章到这里就结束了,最后分享文中提到的 lua jwt库, 地址 https://github.com/chenweiqi/lua_jwt
主要逻辑是c开发的,lua只是实现扩展,有遇到bug喊我!

发表评论

电子邮件地址不会被公开。 必填项已用*标注