PHP实现 Apple ID 登录的服务端验证指南
在 iOS 应用中启用 “通过 Apple 登录”(Sign In with Apple)后,客户端会获取一个 身份令牌(identity token)。该令牌是一个JWT(JSON Web Token),需要由服务端验证其真实性和完整性,然后才能用于在您的系统中创建或登录用户账户。下面我们详细说明 Apple 登录的完整服务端流程,以及如何在PHP中验证 Apple 返回的JWT,包括获取并缓存公钥、验证签名和重要的安全细节。
Apple 登录的服务端流程概览
-
客户端获取身份令牌:用户在 iOS 上通过Apple完成登录后,您的应用会收到一个
identityToken
(以及一个authorizationCode
)。identityToken
实质上是一个JWT字符串,由三部分组成:header(头部)、payload(载荷)和signature(签名),以.
分隔。该JWT由Apple使用其私钥签名,包含了用户标识等信息。 -
传输给服务端:客户端将此
identityToken
发送给您的应用服务器。服务端需要验证该令牌确实由 Apple 签发且未被篡改。Apple没有提供类似Google那样的直接API来验证这个token,因此需要服务端自行完成JWT的验证。 -
服务端验证JWT签名:服务端从Apple提供的公开密钥中找到对应的公钥,验证JWT的签名是否有效。只有使用Apple的公钥成功验证签名,才能确认该令牌确实由Apple签发。
-
解码并校验载荷:签名验证通过后,解码JWT获取其中的payload数据,如用户的唯一标识(
sub
)、邮箱(email
)等,并进一步校验关键字段的有效性(issuer、audience、过期时间、nonce等)。 -
完成登录/注册:验证无误后,可使用JWT中的
sub
作为用户的Apple身份ID,在您数据库中查找或创建对应用户,并利用邮箱等信息完善用户数据。此后,即可认为该用户通过Apple登录得到认证。
提示:Apple还提供了使用
authorizationCode
换取令牌的OAuth接口,但本文主要聚焦于直接验证identityToken
(JWT)的方案。
获取并缓存 Apple 公钥 (JWKS)
Apple 使用其私钥对 identityToken
进行签名,因此服务端需要通过 JWKS(JSON Web Key Set)接口获取 Apple 的公钥集合,并从中提取对应的公钥用于签名验证。JWKS 接口地址为:
https://appleid.apple.com/auth/keys
返回的数据结构类似于以下 JSON 格式:
{"keys": [{"kty": "RSA","kid": "ABCD1234EFGH","use": "sig","n": "...","e": "..."},...]
}
每个密钥包含kid(密钥标识)、alg(算法)、n(模数)、e(指数)等信息。我们需要根据JWT中header部分的kid来选择正确的公钥进行验证。切勿将公钥信息硬编码在服务端,因为Apple可能不定期更换公钥或增加新的密钥。应该每隔一段时间通过上述接口获取最新JWKS,并缓存这些公钥,以减少每次验证时的网络请求和延迟,每条记录中的 kid
字段对应于签发 token 时使用的密钥 ID。需根据该字段匹配正确的公钥。
解析并验证 identityToken
客户端传递过来的 identityToken
是一个标准的 JWT 结构,由三部分组成:头部、载荷和签名。以下是具体步骤:
-
解码 JWT 并提取头部信息
提取kid
值以定位 JWKS 中的具体公钥。 -
加载 Apple 公钥
将 JWKS 返回的公钥转换为适合验证的形式。 -
验证签名有效性
利用firebase/php-jwt
库调用方法验证签名是否合法。没有安装请安装
composer require firebase/php-jwt
示例代码如下:
<?php
require_once __DIR__ . '/vendor/autoload.php';use Firebase\JWT\JWK;
use Firebase\JWT\JWT;// (1) 从客户端获取 Apple 身份令牌 (JWT 字符串)
$identityToken = $_POST['identityToken'] ?? ''; // 这里假设通过POST传入// (2) 获取Apple的JWKS公钥列表(可加缓存机制避免频繁请求)
$jwksJson = file_get_contents('https://appleid.apple.com/auth/keys');
$jwkSet = json_decode($jwksJson, true);
if (!$jwkSet || !isset($jwkSet['keys'])) {exit("无法获取Apple公钥 JWKS");
}// 将JWKS转换为公钥数组(使用kid索引)
$publicKeys = JWK::parseKeySet($jwkSet); // 解析成功将返回以kid为键的公钥数组​:contentReference[oaicite:11]{index=11}try {// (3) 验证JWT签名并解码payload(指定只允许RS256算法)$payload = JWT::decode($identityToken, $publicKeys, ['RS256']);
} catch (\Exception $e) {// 签名验证或解码失败exit("令牌无效:" . $e->getMessage());
}// (4) JWT 验证通过,提取payload中的数据
$payloadData = (array)$payload; // 转换StdClass为关联数组
// 例如:$payloadData 包含 iss, aud, exp, iat, sub, email 等字段// (5) 校验重要字段
if ($payloadData['iss'] !== 'https://appleid.apple.com') {exit("令牌签发者无效");
}
$clientId = '你的App的CLIENT_ID或Bundle ID'; // 开发者在Apple注册的服务ID
if ($payloadData['aud'] !== $clientId) {exit("令牌的受众不匹配");
}
if ($payloadData['exp'] < time()) {exit("令牌已过期");
}
if (isset($payloadData['nonce'])) {// 如果您的登录请求使用了nonce,则在此处验证 nonce 是否匹配$expectedNonce = '当初发送登录请求时的原始nonce或其哈希';if ($payloadData['nonce'] !== $expectedNonce) {exit("Nonce 验证失败");}
}// (6) 利用验证后的用户信息执行后续逻辑
$userId = $payloadData['sub']; // Apple用户唯一ID
$userEmail = $payloadData['email'] ?? ''; // 用户的Email(可能是私隐中继邮件地址)
构造 client_secret
在请求 Apple OAuth 认证的过程中,还需要提供一个名为 client_secret
的参数。这是一个自定义生成的 JWT,包含以下内容:
- iss: 开发者团队 ID (
TeamID
)。 - aud: 固定值
"https://appleid.apple.com"
。 - sub: App Bundle Identifier。
- iat: 当前时间戳。
- exp: 过期时间戳(通常设置为 iat + 1800 秒)。
生成流程如下:
<?php
use Firebase\JWT\JWT;$teamId = 'YOUR_TEAM_ID'; // 替换为实际 Team ID
$clientSecretKeyId = 'KEY_ID'; // 替换为实际 Key ID
$bundleIdentifier = 'BUNDLE_ID'; // 替换为应用的 Bundle Identifier
$pemFilePath = './AuthKey.pem'; // 下载的私钥路径$header = ['alg' => 'ES256','kid' => $clientSecretKeyId,
];$payload = ['iss' => $teamId,'aud' => 'https://appleid.apple.com','sub' => $bundleIdentifier,'iat' => time(),'exp' => time() + 1800, // 设置有效期为半小时
];$keyContent = openssl_pkey_get_private(file_get_contents($pemFilePath));if (!$keyContent) {die("Failed to load private key");
}$clientSecret = JWT::encode($payload, $keyContent, 'ES256', null, $header);echo "Generated client_secret: {$clientSecret}";
?>
利用 ES256 算法生成了一个有效的 client_secret
,供后续认证请求使用。
请求访问令牌
向 Apple 发起 POST 请求以交换最终的访问令牌。URL 地址为:
https://appleid.apple.com/auth/token
所需参数包括但不限于:
grant_type
: 固定值"authorization_code"
.code
: 手机端传来的authorizationCode
.redirect_uri
: 注册的应用回调 URL.client_id
: App 的 Bundle Identifier.client_secret
: 上述生成的结果.
代码片段如下:
$data = http_build_query(['grant_type' => 'authorization_code','code' => $_POST['authorizationCode'],'redirect_uri' => 'REDIRECT_URI', // 替换为实际重定向 URI'client_id' => 'BUNDLE_IDENTIFIER', // 替换为实际 Bundle Identifier'client_secret' => $clientSecret, // 上面生成的 client_secret
]);$options = ['http' => ['method' => 'POST','content' => $data,'header' => 'Content-Type: application/x-www-form-urlencoded'],
];$response = file_get_contents('https://appleid.apple.com/auth/token', false, stream_context_create($options));
var_dump(json_decode($response, true)); // 输出响应数据