OAuth2 授权码流程 PKCE 扩展
解读
在国内一线/二线互联网公司的 PHP 后端面试里,OAuth2 属于“必问”安全考点。面试官抛出“授权码流程 + PKCE”这一组合,通常想验证三件事:
- 你是否真的读过 RFC 6749 与 RFC 7636,而不是只会用 Laravel Socialite“一键登录”;
- 能否把“授权码”与“PKCE”这两个阶段拆清楚,讲明白为什么“有了 HTTPS 还要 PKCE”;
- 能否用 PHP 写出可落地的、符合国密/等保要求的生产级代码,而不是 demo 级“能跑就行”。
如果候选人只背了“code 换 token”六个字,却答不出 PKCE 的 code_challenge 生成时机、存储位置以及校验哈希算法,基本会被判定为“仅会用封装包”,面试分大打折扣。
知识点
- OAuth2 授权码(Authorization Code)流程四步:
(1) 授权链接 → (2) 授权码回调 → (3) 后端拿 code 换 token → (4) 用 token 访问资源。 - 授权码的痛点:code 在浏览器与后端之间“明文”传输,若被拦截即可被重放;移动 App/单页应用无法安全保存 client_secret。
- PKCE(RFC 7636)核心:客户端在步骤 1 额外生成一次性 code_verifier(43–128 位 URL 安全随机串),计算 code_challenge = BASE64URL(SHA256(code_verifier)),一并传给授权服务器;步骤 3 换 token 时再把原 verifier 带上,服务器校验哈希,确保截获 code 也无法换 token。
- 国内合规细节:
• 随机数必须用密码学安全随机源,如 random_bytes(),禁止 mt_rand();
• SHA256 不可被国密 SM3 直接替换,但可在私有 OAuth 服务器内部扩展支持;
• 授权码有效期≤10 min、单次有效,token 需支持刷新且落地数据库加密;
• 日志要留存 6 个月以上,满足《网络安全法》审计要求。 - PHP 技术栈关键点:
• 生成:challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
• 会话存储:把 verifier 存入 PHP 加密会话或 Redis,设置 10 min TTL,禁止存 Cookie;
• 回调校验:先校验 state 防 CSRF,再校验 code,最后用 Guzzle 带 verifier 换 token;
• 异常处理:对 invalid_grant、slow_down、authorization_pending 等错误码做分支重试与告警。
答案
以下示例基于原生 PHP 7.4+,不依赖 Socialite,可直接放进面试白板手写,也可回答“你如何在 Laravel 里改造”:
<?php
declare(strict_types=1);
session_start();
class OAuth2PkceClient
{
private string $clientId = 'your_client_id';
private string $redirectUri = 'https://example.com/callback.php';
private string $authUrl = 'https://auth.example.com/oauth/authorize';
private string $tokenUrl = 'https://auth.example.com/oauth/token';
// 步骤 1:生成授权 URL(含 PKCE)
public function generateAuthLink(): string
{
$verifier = $this->generateVerifier();
$_SESSION['oauth_verifier'] = $verifier;
$_SESSION['oauth_state'] = bin2hex(random_bytes(16));
$challenge = $this->deriveChallenge($verifier);
$query = http_build_query([
'response_type' => 'code',
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'scope' => 'read',
'state' => $_SESSION['oauth_state'],
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
return $this->authUrl . '?' . $query;
}
// 步骤 2:回调地址接收 code
public function handleCallback(array $get): array
{
if (empty($get['state']) || $get['state'] !== ($_SESSION['oauth_state'] ?? '')) {
throw new RuntimeException('state 校验失败');
}
if (empty($get['code'])) {
throw new RuntimeException('缺少授权码');
}
return $this->exchangeCode($get['code']);
}
// 步骤 3:用 code + verifier 换 token
private function exchangeCode(string $code): array
{
$verifier = $_SESSION['oauth_verifier'] ?? '';
if (!$verifier) {
throw new RuntimeException('verifier 丢失');
}
$payload = [
'grant_type' => 'authorization_code',
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'code' => $code,
'code_verifier'=> $verifier,
];
$ch = curl_init($this->tokenUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
CURLOPT_POSTFIELDS => http_build_query($payload),
CURLOPT_TIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true, // 生产必须开
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
if ($errno) {
throw new RuntimeException('网络错误:' . $errno);
}
$data = json_decode($body, true);
if (isset($data['error'])) {
throw new RuntimeException('授权服务器返回:' . $data['error_description']);
}
// 步骤 4:清理 verifier,防止重用
unset($_SESSION['oauth_verifier'], $_SESSION['oauth_state']);
return $data; // 含 access_token, refresh_token, expires_in
}
// 密码学安全随机生成器
private function generateVerifier(): string
{
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
// 计算 S256 challenge
private function deriveChallenge(string $verifier): string
{
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
}
}
// 使用示例
$oauth = new OAuth2PkceClient();
if (!isset($_GET['code'])) {
header('Location: ' . $oauth->generateAuthLink());
exit;
}
try {
$token = $oauth->handleCallback($_GET);
echo '获取成功: ' . $token['access_token'];
} catch (Throwable $e) {
error_log($e->getMessage());
http_response_code(400);
echo '授权失败';
}
面试时可以把上述代码拆成三步讲:
- 生成阶段强调“verifier 只存服务端会话,不到浏览器”;
- 回调阶段强调“先 state 后 code,再 verifier,顺序不能反”;
- 异常阶段强调“invalid_grant 直接抛 400,不友好提示,防钓鱼”。
如能再补充“refresh token 落地数据库时 AES-256-GCM 加密,密钥放华为云 KMS”,可瞬间拉高安全维度得分。
拓展思考
- 如果公司把授权服务器与资源服务器分离,且资源服务器用 Go 重构,PHP 侧如何无状态化验证 access_token?
答:采用 JWT + JWS,授权服务器签发时把公钥发布到 JWKs 端点,PHP 资源网关直接用 lcobucci/jwt 解析,不连库,实现横向扩容。 - 国内小程序登录也走“code 换 session_key”,它与 OAuth2 PKCE 有什么异同?
答:小程序的 code 只能使用一次、有效期 5 min,相当于“授权码”,但微信不暴露 verifier,而是后台用 appsecret 做对称校验;若把 appsecret 硬编码在客户端,等同于 OAuth2 的 client_secret 泄露风险,因此业务方仍需自建后端中转,思路与 PKCE 一致。 - 当授权服务器要求国密 SM2/SM3 双向证书通道,PHP 如何对接?
答:PHP 层仍负责生成 verifier,但 hash 算法换 sm3(code_verifier),并在 TLS 握手阶段使用国密双证;curl 需编译 gmssl 扩展,或把请求转发给内部 Java 网关做国密代理,PHP 侧保持业务无侵入。