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