OAuth2 授权码流程 PKCE 扩展

解读

在国内一线/二线互联网公司的 PHP 后端面试里,OAuth2 属于“必问”安全考点。面试官抛出“授权码流程 + PKCE”这一组合,通常想验证三件事:

  1. 你是否真的读过 RFC 6749 与 RFC 7636,而不是只会用 Laravel Socialite“一键登录”;
  2. 能否把“授权码”与“PKCE”这两个阶段拆清楚,讲明白为什么“有了 HTTPS 还要 PKCE”;
  3. 能否用 PHP 写出可落地的、符合国密/等保要求的生产级代码,而不是 demo 级“能跑就行”。
    如果候选人只背了“code 换 token”六个字,却答不出 PKCE 的 code_challenge 生成时机、存储位置以及校验哈希算法,基本会被判定为“仅会用封装包”,面试分大打折扣。

知识点

  1. OAuth2 授权码(Authorization Code)流程四步:
    (1) 授权链接 → (2) 授权码回调 → (3) 后端拿 code 换 token → (4) 用 token 访问资源。
  2. 授权码的痛点:code 在浏览器与后端之间“明文”传输,若被拦截即可被重放;移动 App/单页应用无法安全保存 client_secret。
  3. PKCE(RFC 7636)核心:客户端在步骤 1 额外生成一次性 code_verifier(43–128 位 URL 安全随机串),计算 code_challenge = BASE64URL(SHA256(code_verifier)),一并传给授权服务器;步骤 3 换 token 时再把原 verifier 带上,服务器校验哈希,确保截获 code 也无法换 token。
  4. 国内合规细节:
    • 随机数必须用密码学安全随机源,如 random_bytes(),禁止 mt_rand();
    • SHA256 不可被国密 SM3 直接替换,但可在私有 OAuth 服务器内部扩展支持;
    • 授权码有效期≤10 min、单次有效,token 需支持刷新且落地数据库加密;
    • 日志要留存 6 个月以上,满足《网络安全法》审计要求。
  5. PHP 技术栈关键点:
    • 生成:verifier=bin2hex(randombytes(32));•计算:verifier = bin2hex(random_bytes(32)); • 计算: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 '授权失败';
}

面试时可以把上述代码拆成三步讲:

  1. 生成阶段强调“verifier 只存服务端会话,不到浏览器”;
  2. 回调阶段强调“先 state 后 code,再 verifier,顺序不能反”;
  3. 异常阶段强调“invalid_grant 直接抛 400,不友好提示,防钓鱼”。
    如能再补充“refresh token 落地数据库时 AES-256-GCM 加密,密钥放华为云 KMS”,可瞬间拉高安全维度得分。

拓展思考

  1. 如果公司把授权服务器与资源服务器分离,且资源服务器用 Go 重构,PHP 侧如何无状态化验证 access_token?
    答:采用 JWT + JWS,授权服务器签发时把公钥发布到 JWKs 端点,PHP 资源网关直接用 lcobucci/jwt 解析,不连库,实现横向扩容。
  2. 国内小程序登录也走“code 换 session_key”,它与 OAuth2 PKCE 有什么异同?
    答:小程序的 code 只能使用一次、有效期 5 min,相当于“授权码”,但微信不暴露 verifier,而是后台用 appsecret 做对称校验;若把 appsecret 硬编码在客户端,等同于 OAuth2 的 client_secret 泄露风险,因此业务方仍需自建后端中转,思路与 PKCE 一致。
  3. 当授权服务器要求国密 SM2/SM3 双向证书通道,PHP 如何对接?
    答:PHP 层仍负责生成 verifier,但 hash 算法换 sm3(code_verifier),并在 TLS 握手阶段使用国密双证;curl 需编译 gmssl 扩展,或把请求转发给内部 Java 网关做国密代理,PHP 侧保持业务无侵入。