集成Apple登录

  • 客户端使用idToken(JWT)给到服务器,服务器使用苹果公钥解开JWT

identityToken参考样例:

// jwt 格式 该token的有效期是10分钟(通过.隔开,第一段header,第二段payload,第三段signature)
eyJraWQiOiJBSURPUEsxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLnNreW1pbmcuZGV2aWNlbW9uaXRvciIsImV4cCI6MTU2NTY2ODA4NiwiaWF0IjoxNTY1NjY3NDg2LCJzdWIiOiIwMDEyNDcuOTNiM2E3OTlhN2M4NGMwY2I0NmNkMDhmMTAwNzk3ZjIuMDcwNCIsImNfaGFzaCI6Ik9oMmFtOWVNTldWWTNkcTVKbUNsYmciLCJhdXRoX3RpbWUiOjE1NjU2Njc0ODZ9.e-pdwK4iKWErr_Gcpkzo8JNi_MWh7OMnA15FvyOXQxTx0GsXzFT3qE3DmXqAar96nx3EqsHI1Qgquqt2ogyj-lLijK_46ifckdqPjncTEGzVWkNTX8uhY7M867B6aUnmR7u-cf2HsmhXrvgsJLGp2TzCI3oTp-kskBOeCPMyTxzNURuYe8zabBlUy6FDNIPeZwZXZqU0Fr3riv2k1NkGx5MqFdUq3z5mNfmWbIAuU64Z3yKhaqwGd2tey1Xxs4hHa786OeYFF3n7G5h-4kQ4lf163G6I5BU0etCRSYVKqjq-OL-8z8dHNqvTJtAYanB3OHNWCHevJFHJ2nWOTT3sbw
 
// header 解码
{"kid":"AIDOPK1","alg":"RS256"} 其中kid对应上文说的密钥id
 
// claims 解码
{
"iss":"https://appleid.apple.com",  // 苹果签发的标识
"aud":"com.skyming.devicemonitor", // 接收者的APP ID
"exp":1565668086,"iat":1565667486,
"sub":"001247.93b3a799a7c84c0cb46cd08f100797f2.0704", //用户的唯一标识
"c_hash":"Oh2am9eMNWVY3dq5JmClbg",
"auth_time":1565667486
}

其中 iss标识是苹果签发的,aud是接收者的APP ID,该token的有效期是10分钟,sub就是用户的唯一标识

# 苹果api
# 公钥
https://appleid.apple.com/auth/keys




# 生成idToken(需要添加浏览器UA, 否则invalid_client)
https://appleid.apple.com/auth/token

验证demo参见cert目录

响应内容:

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

服务端从上面的数组中的公钥验证客户端的idToken

详细步骤

idToken方式登录(适用于客户端)

1. 创建App ID,添加Sign in with Apple支持

2. 客户端获取idToken请求服务端

3. 服务端获取idToken进行验证(node版本)

const NodeRSA = require('node-rsa');
const axios = require('axios');
const jwt = require('jsonwebtoken');
const express = require('express')
const app = express()


/*
alg:string
The encryption algorithm used to encrypt the token.
e: string
The exponent value for the RSA public key.
kid: string
A 10-character identifier key, obtained from your developer account.
kty: string
The key type parameter setting. This must be set to "RSA".
n: string
The modulus value for the RSA public key.
use: string
* kid,为密钥id标识,签名算法采用的是RS256(RSA 256 + SHA 256),kty常量标识使用RSA签名算法,其公钥参数
*/
// 获取苹果的公钥
async function getApplePublicKey() {
    let res = await axios.request({
        method: "GET",
        url: "https://appleid.apple.com/auth/keys",
        headers: {
            'Content-Type': 'application/json'
        }
    })

    let keyvalues = {};

    console.log(res.data)
    // 循环验证
    res.data.keys.map((key) => {
        // parse key
        const rsa = new NodeRSA();
        rsa.importKey({
            n: Buffer.from(key.n, 'base64'),
            e: Buffer.from(key.e, 'base64')
        }, 'components-public');
        const publicKey = rsa.exportKey(['public']);
        // cache key
        keyvalues[key.kid] = publicKey;
        // return public key string
        return publicKey;
    });
    return keyvalues;
};

// 验证id_token
// id_token:  Identity token
// audience : app bundle id  , 可以不用
// subject : userId , 可以不用
async function verifyIdToken(id_token, audience, subject, callback) {
    let body = Buffer.from(id_token.split('.')[0], 'base64').toString()
    let json = JSON.parse(body)
    console.log(json)
    const map = await getApplePublicKey();

    let publicKey = map[json.kid];

    jwt.verify(id_token, publicKey, { algorithms: 'RS256', issuer: "https://appleid.apple.com", audience, subject }, (err, decode) => {

        if (err) {
            //message: invalid signature  / jwt expired
            console.log("JJ: verifyIdToken -> error", err.name, err.message, err.date);
            callback && callback(err);
        } else if (decode) {

            // let decode = {
            //     iss: 'https://appleid.apple.com',
            //     aud: 'xxxxxxxx',   
            //     exp: 1579171507,
            //     iat: 1579170907,
            //     sub: 'xxxxxxxx.xxxx',
            //     c_hash: 'xxxxxxxxxxxx',
            //     email: 'xxxxx@qq.com',
            //     email_verified: 'true',
            //     auth_time: 1579170907
            // }
            console.log("JJ: verifyIdToken -> decode", decode)
            callback && callback(decode);
            // sub 就是用户的唯一标识, 服务器可以保存它用来检查用户有没用过apple pay login , 至于用户第一次Login时,服务器就默认开一个member 给用户, 还是见到没login 过就自己再通过app 返回到注册页面再接着注册流程, 最后再pass userId 到server 保存. 这个看公司需求.
        }
    });
};

app.get('/', async function (req, res) {
  // 客户端请求服务器的idToken
    let idtoken = 'eyJraWQiOiJlWGF1bm1MIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY24ucXVpY2tjbi5hcHAiLCJleHAiOjE1OTQ5NTY5MzAsImlhdCI6MTU5NDk1NjMzMCwic3ViIjoiMDAxNjY1LmIwMWUyMTdmYzEzNDRmMTdhYjM2NTM5NmMxN2Y1ZTFiLjA3NTEiLCJhdF9oYXNoIjoiMldDUUhVQVNQSm56QTJWWTRJczM1QSIsImVtYWlsIjoiaWQ0OW5yczd0Y0Bwcml2YXRlcmVsYXkuYXBwbGVpZC5jb20iLCJlbWFpbF92ZXJpZmllZCI6InRydWUiLCJpc19wcml2YXRlX2VtYWlsIjoidHJ1ZSIsImF1dGhfdGltZSI6MTU5NDk1NjI4Miwibm9uY2Vfc3VwcG9ydGVkIjp0cnVlfQ.KQNURkgdOxdf944lTly2fnvivIdSgeBf97IQkMSzGIa-PolPNHI4L-7jHveEm_qXE4ABRDczSSB_gpW7w4OCbHz7ewbF7C-YPE2siFvmasmHxVRyaRH_HUuRlU8KLXcOs2gLMLL-bXhRJGQUaFm41sGIxsxbY69JbHI_18Qz8S2AOR3F45XaxYjmEwpmT8YD05DILvI6DqCA9hwBT2YsLDq68ZvXLxdYI66EJC7n8dHqWGRQzrU53Z5DNYv_mZFWwwl04p0VgbbxQbhFzEV9BCDFoL7ytVG_NFiyOU1_GO69u-2p3Mt0EvDnlYhXkvZr5wqdpN53AI9GwegbqCieBw'
    
    let data = await verifyIdToken(idtoken, '填入bundleId', null);

    res.send(data);
})

app.listen(3000)

4. 响应客户端验证结果,成功即绑定用户信息

authorization_code方式登录(适用于网页端)

1. 创建App ID

2. 勾选Sign in with Apple功能

3. 创建Services ID

注意:其中需要填入的Identifier是接下来要使用的client_id

4. 配置用于验证数据的域名和回调地址

5. 获取自己应用的key_idkey_file

6. 本地创建client_secret.rb文件

# ruby版本

# gem install jwt
require 'jwt'

# key_file = 'key.txt'
key_file = 'AuthKey_54N6776F2C.p8' # apple登录创建的key
key_id = '填入keyId' # apple登录创建的key的id
team_id = '填入teamId'
client_id = '填入bundleId' # 即app_id

ecdsa_key = OpenSSL::PKey::EC.new IO.read key_file

headers = {
  'alg' => 'ES256',
  'kid' => key_id
}

claims = {
	'iss' => team_id,
	'iat' => Time.now.to_i,
	'exp' => Time.now.to_i + 86400*180,
	'aud' => 'https://appleid.apple.com',
	'sub' => client_id,
}

token = JWT.encode claims, ecdsa_key, 'ES256', headers

puts token

执行命令,得到client_secret

su@Mac ~> ruby client_secret.rb 
eyJhbGciOiJFUzI1NiIsImtpZCI6IjU0TjY3NzZGMkMifQ.eyJpc3MiOiJSNUY5MzJDNTNEIiwiaWF0IjoxNjAwMjIyNTA1LCJleHAiOjE2MTU3NzQ1MDUsImF1ZCI6Imh0dHBzOi8vYXBwbGVpZC5hcHBsZS5jb20iLCJzdWIiOiJjbi5xdWlja2NuLmFwcCJ9.ujJNrutWSjwkJGYQrzPhywkrdWo93bnT6rOiiPrPJYcnOvHPhuR0H_EoAV7Rj-p1iLg8QFzWPWO4sPJt0V5X6w

7. 网页端通过上面获取的client_secret进行登录

<?php
session_start();

$client_id = '填入client_id';
// 上面获取的client_secret
$client_secret = 'eyJhbGciOiJFUzI1NiIsImtpZCI6IjU0TjY3NzZGMkMifQ.eyJpc3MiOiJSNUY5MzJDNTNEIiwiaWF0IjoxNjAwMjI3NDUwLCJleHAiOjE2MTU3Nzk0NTAsImF1ZCI6Imh0dHBzOi8vYXBwbGVpZC5hcHBsZS5jb20iLCJzdWIiOiJjbi5xdWlja2NuLmFwcCJ9.hdi5m83Z2sPPykjUREw9uNqDwgcjjplHtndUPpGPepNjKpABw_U3TOzvR2KRc3r_GYw_svKR5JEFcJOPcGZflg';
// 填入自己配置的url, 可先用下面地址测试流程
$redirect_uri = 'https://example-app.com/redirect';

///////////////////////////////////////////////////////////////////////
// 处理用户Apple登录成功后的回调
if(isset($_POST['code'])) {

  if($_SESSION['state'] != $_POST['state']) {
    die('Authorization server returned an invalid state parameter');
  }

  // Token endpoint docs: 
  // https://developer.apple.com/documentation/signinwithapplerestapi/generate_and_validate_tokens

  $response = http('https://appleid.apple.com/auth/token', [
    'grant_type' => 'authorization_code',
    'code' => $_POST['code'],
    'redirect_uri' => $redirect_uri,
    'client_id' => $client_id,
    'client_secret' => $client_secret,
  ]);

  if(!isset($response->access_token)) {
    echo '<p>Error getting an access token:</p>';
    echo '<pre>'; print_r($response); echo '</pre>';
    echo '<p><a href="/">Start Over</a></p>';
    die();
  }

  echo '<h3>Access Token Response</h3>';
  echo '<pre>'; print_r($response); echo '</pre>';


  $claims = explode('.', $response->id_token)[1];
  $claims = json_decode(base64_decode($claims));

  echo '<h3>Parsed ID Token</h3>';
  echo '<pre>';
  print_r($claims);
  echo '</pre>';

  die();
}

///////////////////////////////////////////////////////////////////////

$_SESSION['state'] = bin2hex(random_bytes(5));

$authorize_url = 'https://appleid.apple.com/auth/authorize'.'?'.http_build_query([
  'response_type' => 'code',
  'response_mode' => 'form_post',
  'client_id' => $client_id,
  'redirect_uri' => $redirect_uri,
  'state' => $_SESSION['state'],
  'scope' => 'name email',
]);

echo '<a href="'.$authorize_url.'">Sign In with Apple</a>';

8. 待用户登录后,将会重定向到redirect_uri

响应信息如下:

Form Data:
state: d4523690c6
code: c4d37a0e3c21947dca3668bea92da6adb.0.rrwwv.jk58r6H_MbDYRJhmb6F-UQ

9. 后续尚未验证过

登录url:

https://appleid.apple.com/auth/authorize?response_type=code&response_mode=form_post&client_id=$client_id&redirect_uri=$redirect_url&state=$state&scope=name+email

范例url:

https://appleid.apple.com/auth/authorize?response_type=code&response_mode=form_post&client_id=com.example.appsign&redirect_uri=https%3A%2F%2Fexample-app.com%2Fapi%2Fapple%2FoauthNotify&state=66232e40d8&scope=name+email

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!