最近帮隔壁组排查一个安全漏洞,看得我冷汗直流。他们那个单页应用(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 生成 challengeasync 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 读取?欢迎讨论你们的存储方案。