接第三方登录看起来是一件已经被解决过一万次的事:拿 OIDC 库、换 code、验签、按邮箱找用户。前两家接完确实是这个感觉。
问题出在第三家。「同一套 OIDC」并不意味着同一套断言——各家 ID token 里到底承诺了什么,差别大到可以把一段能正常工作的代码变成一个账号劫持后门。下面这些是给这个站接 Google、Google One Tap 和微软登录时踩到的,全部有代码可查。
先说这套东西长什么样
一个 WordPress 站,自己有一套邮箱密码账号体系(读者要能评论、能收通知),再叠上第三方登录。目标很朴素:已有站内账号的读者,用第三方登录进来应该落到自己那个账号里,而不是凭空多出一个。
「落到自己那个账号里」这句话,就是全部风险的来源。
微软不告诉你邮箱验证过没有,而 Google 会
Google 的 ID token 里有 email_verified。正因为有它,按邮箱把 Google 身份关联到一个已存在的站内账号才是安全的——Google 在替你担保这个地址确实属于登录的人:
$emailVerified = $payload['email_verified'] ?? false;
$emailVerified = $emailVerified === true || $emailVerified === 'true'
|| $emailVerified === '1' || $emailVerified === 1;
if ($email === '' || ! is_email($email) || ! $emailVerified) {
return new WP_Error('email_not_verified', 'Google email is not verified.');
}
(顺带一个小坑:这个 claim 在不同来源下可能是布尔 true,也可能是字符串 "true"。只写 === true 会把合法登录判成未验证;只写 if ($payload['email_verified']) 又会把字符串 "false" 当成真。四种都列出来是最省事的写法。)
接微软的时候,最自然的做法是把这段复制过去改个变量名。而微软的 ID token 里根本没有 email_verified 这个 claim。
尤其是个人 MSA 账号——微软从来没有声称过那个地址已经被验证。照搬 Google 的按邮箱关联,结果是:任何人拿别人的邮箱地址注册一个 MSA,点一下「用微软登录」,就进了对方的站内账号。不需要密码,不需要收验证邮件,因为整条链路上没有任何一步真的验证过那个地址。
所以微软这条路径只按 sub 关联。邮箱撞上已有账号时直接拒绝:
// Deliberately NOT the Google behaviour. Microsoft asserts no
// email_verified claim, so an address matching an existing account
// proves nothing and must not grant access to it.
$existing = email_exists($email);
if (is_int($existing) && $existing > 0) {
return new WP_Error('ms_email_taken', 'That email already belongs to a site account.');
}
用户会看到一句「这个邮箱已经属于一个站内账号,请先正常登录,再到资料页连接微软」。这比 Google 那条路难用,是故意的——它是对「微软实际断言了什么」唯一安全的读法。
更一般的说法:把一个第三方身份关联到已有账号,靠的不是邮箱相同,而是有人替你担保这个邮箱。没有担保就没有关联,只能新建账号或者要求用户先证明自己是谁。
common 租户下的 issuer 不是常量
验 ID token 的标准步骤里有一条「比对 iss」,而多数教程给的是一个写死的字符串。微软用 common 授权端点时,iss 是随租户变的,写死必然对不上;而如果因为对不上就把这条检查删掉,等于放弃了 issuer 校验。
正确做法是从 tid claim 把 issuer 拼出来再比:
$tid = (string) ($payload['tid'] ?? '');
if ($tid === '' || ! preg_match('/^[A-Za-z0-9\-]{1,64}$/', $tid)) {
return new WP_Error('invalid_id_token', 'Missing Microsoft tenant claim.');
}
if ((string) ($payload['iss'] ?? '')
!== sprintf('https://login.microsoftonline.com/%s/v2.0', $tid)) {
return new WP_Error('invalid_id_token', 'Invalid Microsoft ID token issuer.');
}
tid 那条正则不是洁癖。它来自 token 里的一个字段,而下一行要拿它去拼一个字符串做比对——任何进入比对目标的、来自被验证对象自身的值,都得先约束形状,否则这条检查就变成了「自己和自己比」。
微软发的是 JWK 不是 PEM,但里面有现成的证书
Google 的 oauth2/v1/certs 直接给 PEM,openssl_verify 拿来就能用。微软给的是 JWKS,key 以 n/e(模数和指数)的形式描述。
到这一步很容易走上「手工从 n/e 拼 DER 编码的 RSA 公钥」这条路——要写 ASN.1 长度前缀,要处理最高位为 1 时补零字节,几十行不好调的代码。
其实不必。微软的每个 key 都带 x5c,那是 base64 的 DER 证书链,包上 PEM 头尾就是一张能直接用的证书:
$keys[(string) $key['kid']] = "-----BEGIN CERTIFICATE-----\n"
. chunk_split((string) $key['x5c'][0], 64, "\n")
. "-----END CERTIFICATE-----\n";
chunk_split 那个 64 是 PEM 的行宽要求,不是随手写的——不换行的 base64 有些 OpenSSL 版本不认。
一个 per-request 的 nonce,既不能进 HTML 也不能进数据库
Google One Tap 要求前端传一个 nonce,用来防重放。标准做法是服务端生成、写进页面、验证时比对。
这个站的页面被缓存两层:nginx 的 fastcgi_cache 在 PHP 前面,Cloudflare 在最外面。一个写进 HTML 的 per-request nonce 会被缓存下来,然后原样发给之后每一个访客——一个所有人共用的 nonce,正好是它存在要防的那件事。
所以页面里不放任何 per-request 的东西,nonce 由前端 POST 一个端点去取。POST 天然被两层缓存跳过,不需要额外配置缓存规则(少配一条规则,就少一处将来被人「优化」掉的地方)。
下一个问题接着来:这个端点是公开且无鉴权的。把 nonce 存进 transient,意味着任何人循环调用就能往 wp_options 里无限写行——这个站没有对象缓存,transient 直接落库。
做法是让 nonce 自带签名、完全不落库:
$body = base64url(json_encode([
'n' => wp_generate_password(24, false, false),
't' => time(),
]));
// 再用站点密钥做 HMAC 附在后面
签发一个 nonce 于是不占任何存储。一次性校验没有丢,只是挪到了后面那一步——挪到「已经拿着一个 Google 签过名的合法 token」的位置。那一步攻击者刷不动,因为他得先让 Google 给他签个名。
这个取舍值得单独记一句:无状态的凭据不是没有状态,是把需要状态的那一步推迟到一个更贵的位置。
One Tap 的失败是静默的
One Tap 不弹的时候,页面上没有任何报错,控制台也干净。两个原因都属于「配置对了才会有东西,配错了什么都没有」:
其一,Google Cloud Console 里必须配「已获授权的 JavaScript 来源」。只配 redirect_uri 是不够的——那是给跳转式登录用的;One Tap 走的是浏览器里的 GSI 客户端,它校验的是 origin。缺了就静默不弹。
其二,新版 Chrome 走 FedCM,use_fedcm_for_prompt: true 不加同样静默失效。
这类失败的麻烦不在于难修,在于它和「代码写错了」长得一模一样。我在这里浪费的时间远多于写那段代码的时间。判断方法是先确认 GSI 脚本本身加载成功、回调注册成功——如果这两步都对而提示框不出现,那就是配置,不是代码。
没接苹果登录,因为门槛不在代码
苹果这条最后没做,原因和技术难度无关:
其一,Apple Developer Program $99/年。不是会员就拿不到 Services ID 和 .p8 私钥,整条路走不通。
其二,也是更该记的一条:苹果的 client_secret 不是一个固定字符串,而是用 ES256 签的 JWT,最长 6 个月过期。也就是说接完之后必须再做一个定时重签的任务,否则登录会在半年后的某一天毫无征兆地全挂——而那天你多半已经忘了这回事。
另外两条如果要接得先知道:苹果只在首次授权返回用户姓名,错过就再也拿不到;用私密转发邮箱的用户,你得先到苹果那边注册发信域名,否则站点发出去的邮件会被退回。
这套设计的既有代价
微软那条路径确实更难用。邮箱撞车时用户必须先用原账号登录、再到资料页连接。这是一次真实的转化损失,换来的是「没有担保就不关联」这条线不被破例。
按 sub 关联意味着身份绑定在提供方的账号上。用户换掉那个微软账号,站内账号就连不回去了,只能人工处理。
无状态 nonce 的一次性校验被推迟了。它在真正重要的那一步仍然生效,但签发端点本身不再记得自己发过什么——这是拿「不可被刷爆」换来的,不是免费的。
One Tap 只对登出访客、且只在浏览器空闲后加载。第三方脚本不上关键路径是移动端性能的硬要求,代价是有些访客根本不会看到那个提示框。
还有一条留在原地的:只有两家。苹果那 $99 和半年一次的重签任务,对一个个人站点来说,暂时没有值得付的理由。
