最近帮隔壁组排查一个安全漏洞,看得我冷汗直流。他们那个单页应用(SPA)居然还在用十几年前就该被扫进垃圾堆的 OAuth2 隐式授权流(Implicit Flow)。用户登录完,认证中心直接把 Access Token 挂在 URL 的 Hash 后面重定向回来。这玩意简直是个巨大的安全漏洞,浏览器历史记录、Referer 头部、甚至第三方统计脚本,分分钟就能把这个 Token 扒个精光。现在主流的认证标准早就给隐式授权流判了死刑,SPA 必须强制开启授权码流程(Authorization Code Flow)外加 PKCE 校验。

所谓 PKCE(Proof Key for Code Exchange),简单来说就是为了防止中间人拦截授权码(Authorization Code)并恶意换取 Token。核心逻辑是在客户端随机生成一个高强度的临时密钥(Code Verifier),通过 SHA-256 算法计算出挑战码(Code Challenge)。网关或认证中心在发放授权码时记录挑战码,等到客户端拿授权码换 Token 时,必须带上原始的临时密钥,由服务端验算。如果对不上,直接当场拒绝。

为了搞定这个流程,我们不需要引入什么臃肿的三方 SDK,纯前端用 Web Crypto API 就能手写一个安全且无依赖的校验码生成和重定向拦截工具。下面是我们项目里实际封装的完整 TypeScript 实现:

interface PKCEPair {
  codeVerifier: string;
  codeChallenge: string;
}

// 产生随机高强度 verifier 串
function generateRandomString(length: number): string {
  const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
  const values = new Uint8Array(length);
  crypto.getRandomValues(values);
  return Array.from(values)
    .map((x) => possible[x % possible.length])
    .join("");
}

// 基于 SHA-256 生成 challenge
async function generateChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  
  // Base64URL 编码处理,必须替换掉特殊字符
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

export async function initiatePKCELoginFlow(clientId: string, authEndpoint: string, redirectUri: string) {
  const verifier = generateRandomString(64);
  const challenge = await generateChallenge(verifier);

  // 必须把 verifier 存在本地,后面换 Token 时候拿出来用
  window.localStorage.setItem("pkce_code_verifier", verifier);

  const authUrl = new URL(authEndpoint);
  authUrl.searchParams.append("response_type", "code");
  authUrl.searchParams.append("client_id", clientId);
  authUrl.searchParams.append("redirect_uri", redirectUri);
  authUrl.searchParams.append("code_challenge", challenge);
  authUrl.searchParams.append("code_challenge_method", "S256");
  authUrl.searchParams.append("scope", "openid profile email");

  window.location.href = authUrl.toString();
}

export async function handlePKCETokenExchange(
  tokenEndpoint: string,
  clientId: string,
  redirectUri: string
): Promise<any> {
  const urlParams = new URLSearchParams(window.location.search);
  const code = urlParams.get("code");
  if (!code) {
    throw new Error("Authorization code not found in query string");
  }

  const verifier = window.localStorage.getItem("pkce_code_verifier");
  if (!verifier) {
    throw new Error("PKCE code verifier missing from localStorage");
  }

  // 及时清理
  window.localStorage.removeItem("pkce_code_verifier");

  const payload = new URLSearchParams();
  payload.append("grant_type", "authorization_code");
  payload.append("client_id", clientId);
  payload.append("code", code);
  payload.append("redirect_uri", redirectUri);
  payload.append("code_verifier", verifier);

  const response = await fetch(tokenEndpoint, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: payload.toString(),
  });

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`Token exchange failed: ${errorText}`);
  }

  return response.json();
}

改完这套方案之后,团队里的前端心智负担小了很多,再也不需要担心 URL 泄漏 Access Token 的大坑。当然,这里唯一需要提防的地方是 localStorage 的安全性。如果页面被注入了 XSS,那保存在里面的 code_verifier 还是会有瞬间被窃取的可能。所以我们在项目里硬编码开启了 CSP 策略,并严格管理第三方 Script 的引入。

话说回来,关于登录后的 Access Token 和 Refresh Token,你们现在是存在 localStorage 还是直接放到 HttpOnly 的 Secure Cookie 里以彻底杜绝前端 JS 读取?欢迎讨论你们的存储方案。