本文档详细说明接入 user.blym.top OAuth 2.0服务时的安全最佳实践,帮助您构建安全可靠的应用。
OAuth 2.0是一个安全的授权协议,但实现不当可能导致安全漏洞。以下是关键安全要点:
┌─────────────────────────────────────────────────────────────────────────┐
│ OAuth安全要点 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 传输安全 │
│ └─ 必须使用HTTPS │
│ │
│ 2. State参数 │
│ └─ 防止CSRF攻击 │
│ │
│ 3. PKCE扩展 │
│ └─ 防止授权码拦截 │
│ │
│ 4. 令牌存储 │
│ └─ 安全存储,防止泄露 │
│ │
│ 5. 作用域最小化 │
│ └─ 只请求必要的权限 │
│ │
│ 6. 令牌生命周期 │
│ └─ 及时刷新,安全销毁 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
所有OAuth通信必须使用HTTPS:
| 端点 | 要求 |
|---|---|
| 授权端点 | 必须HTTPS |
| 令牌端点 | 必须HTTPS |
| 用户信息端点 | 必须HTTPS |
| 回调地址 | 生产环境必须HTTPS |
<?php
// PHP: 启用证书验证
$ch = curl_init('https://user.blym.top/oauth/token.php');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
// 不要禁用证书验证!
// curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 危险!
?>
// JavaScript: 浏览器自动验证证书
// Node.js环境
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: true // 不要设置为false!
});
不要在URL中传递敏感信息:
// 错误示例:在URL中传递令牌
window.location.href = `/profile?token=${accessToken}`; // 危险!
// 正确示例:使用Authorization头
fetch('/api/profile', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
跨站请求伪造(CSRF)攻击者诱导用户在已登录状态下执行非预期操作。
在OAuth中,攻击者可能:
State参数是防止CSRF攻击的关键:
┌─────────────────────────────────────────────────────────────────────────┐
│ State参数工作流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 客户端生成随机state │
│ const state = crypto.randomUUID(); │
│ │
│ 2. 保存state到安全存储 │
│ sessionStorage.setItem('oauth_state', state); │
│ │
│ 3. 将state包含在授权请求中 │
│ /authorize?...&state=${state} │
│ │
│ 4. 授权服务器原样返回state │
│ /callback?code=xxx&state=${state} │
│ │
│ 5. 客户端验证state │
│ if (returnedState !== savedState) { throw Error; } │
│ │
└─────────────────────────────────────────────────────────────────────────┘
// 完整的state处理
class OAuthStateHandler {
// 生成state
static generate() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, byte =>
byte.toString(16).padStart(2, '0')
).join('');
}
// 保存state
static save(state) {
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_state_timestamp', Date.now().toString());
}
// 验证state
static verify(returnedState) {
const savedState = sessionStorage.getItem('oauth_state');
const timestamp = parseInt(sessionStorage.getItem('oauth_state_timestamp') || '0');
// 清除已使用的state
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_state_timestamp');
// 检查state是否过期(10分钟有效期)
if (Date.now() - timestamp > 10 * 60 * 1000) {
return { valid: false, reason: 'State expired' };
}
// 检查state是否匹配
if (!savedState || returnedState !== savedState) {
return { valid: false, reason: 'State mismatch' };
}
return { valid: true };
}
}
// 使用示例
function startAuth() {
const state = OAuthStateHandler.generate();
OAuthStateHandler.save(state);
const authUrl = `https://user.blym.top/oauth/authorize.php?` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`response_type=code&` +
`state=${state}`;
window.location.href = authUrl;
}
function handleCallback() {
const urlParams = new URLSearchParams(window.location.search);
const result = OAuthStateHandler.verify(urlParams.get('state'));
if (!result.valid) {
throw new Error(`CSRF validation failed: ${result.reason}`);
}
// 继续处理授权码
const code = urlParams.get('code');
// ...
}
<?php
// PHP实现
class OAuthStateHandler {
public static function generate() {
return bin2hex(random_bytes(32));
}
public static function save($state) {
$_SESSION['oauth_state'] = $state;
$_SESSION['oauth_state_time'] = time();
}
public static function verify($returnedState) {
$savedState = $_SESSION['oauth_state'] ?? '';
$stateTime = $_SESSION['oauth_state_time'] ?? 0;
// 清除已使用的state
unset($_SESSION['oauth_state']);
unset($_SESSION['oauth_state_time']);
// 检查过期
if (time() - $stateTime > 600) {
return ['valid' => false, 'reason' => 'State expired'];
}
// 检查匹配
if (empty($savedState) || $returnedState !== $savedState) {
return ['valid' => false, 'reason' => 'State mismatch'];
}
return ['valid' => true];
}
}
?>
攻击者可能:
PKCE通过密码学绑定防止授权码被窃取使用:
┌─────────────────────────────────────────────────────────────────────────┐
│ PKCE工作原理 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 授权请求阶段: │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 1. 生成 code_verifier (随机字符串) │ │
│ │ 2. 计算 code_challenge = SHA256(code_verifier) │ │
│ │ 3. 发送 code_challenge 到授权服务器 │ │
│ │ 4. 服务器保存 code_challenge │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 令牌请求阶段: │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 1. 发送 code_verifier 到令牌端点 │ │
│ │ 2. 服务器计算 SHA256(code_verifier) │ │
│ │ 3. 比对是否与 code_challenge 匹配 │ │
│ │ 4. 匹配则返回令牌,否则拒绝 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 攻击者即使拦截了授权码: │
│ - 没有code_verifier,无法换取令牌 │
│ - code_verifier从未在网络中传输(授权请求只发送challenge) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
class PKCEHandler {
// 生成code_verifier
// 要求:43-128个字符,使用 [A-Z][a-z][0-9]-._~
static generateVerifier() {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const array = new Uint8Array(64);
crypto.getRandomValues(array);
return Array.from(array, byte => charset[byte % charset.length]).join('');
}
// 生成code_challenge (S256方法)
static async generateChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return this.base64UrlEncode(new Uint8Array(digest));
}
// Base64 URL编码
static base64UrlEncode(buffer) {
let binary = '';
buffer.forEach(byte => {
binary += String.fromCharCode(byte);
});
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// 完整流程
static async generate() {
const verifier = this.generateVerifier();
const challenge = await this.generateChallenge(verifier);
return { verifier, challenge };
}
}
// 使用示例
async function startAuthWithPKCE() {
// 生成PKCE参数
const pkce = await PKCEHandler.generate();
// 保存verifier(令牌请求时需要)
sessionStorage.setItem('code_verifier', pkce.verifier);
// 生成state
const state = OAuthStateHandler.generate();
OAuthStateHandler.save(state);
// 构建授权URL
const params = new URLSearchParams({
client_id: 'your_client_id',
redirect_uri: 'https://example.com/callback',
response_type: 'code',
scope: 'openid profile email',
state: state,
code_challenge: pkce.challenge,
code_challenge_method: 'S256'
});
window.location.href = `https://user.blym.top/oauth/authorize.php?${params}`;
}
// 处理回调
async function handleCallbackWithPKCE() {
const urlParams = new URLSearchParams(window.location.search);
// 验证state
const stateResult = OAuthStateHandler.verify(urlParams.get('state'));
if (!stateResult.valid) {
throw new Error(`State validation failed: ${stateResult.reason}`);
}
const code = urlParams.get('code');
const verifier = sessionStorage.getItem('code_verifier');
if (!verifier) {
throw new Error('PKCE verifier not found');
}
// 用授权码和verifier换取令牌
const response = await fetch('https://user.blym.top/oauth/token.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://example.com/callback',
client_id: 'your_client_id',
code_verifier: verifier
})
});
// 清除verifier
sessionStorage.removeItem('code_verifier');
return response.json();
}
<?php
class PKCEHandler {
// 生成code_verifier
public static function generateVerifier() {
$charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
$verifier = '';
for ($i = 0; $i < 64; $i++) {
$verifier .= $charset[random_int(0, strlen($charset) - 1)];
}
return $verifier;
}
// 生成code_challenge
public static function generateChallenge($verifier) {
$hash = hash('sha256', $verifier, true);
return self::base64UrlEncode($hash);
}
// Base64 URL编码
private static function base64UrlEncode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
// 完整流程
public static function generate() {
$verifier = self::generateVerifier();
$challenge = self::generateChallenge($verifier);
return ['verifier' => $verifier, 'challenge' => $challenge];
}
}
?>
| 存储位置 | 安全性 | 说明 |
|---|---|---|
| 服务器数据库(加密) | ⭐⭐⭐⭐⭐ | 最安全,适合服务器端应用 |
| HttpOnly Cookie | ⭐⭐⭐⭐⭐ | 防XSS,需要CSRF防护 |
| Session Storage | ⭐⭐⭐⭐ | 页面关闭后清除 |
| Local Storage | ⭐⭐ | 易受XSS攻击,不推荐 |
| URL参数 | ⭐ | 会泄露,禁止使用 |
// 推荐方案:Session Storage + 定期清理
class SecureTokenStorage {
constructor() {
this.storage = sessionStorage;
this.tokenKey = 'oauth_tokens';
}
save(tokens) {
const data = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: Date.now() + tokens.expires_in * 1000,
created_at: Date.now()
};
this.storage.setItem(this.tokenKey, JSON.stringify(data));
}
get() {
const data = this.storage.getItem(this.tokenKey);
return data ? JSON.parse(data) : null;
}
clear() {
this.storage.removeItem(this.tokenKey);
}
// 检查是否需要清理过期令牌
cleanup() {
const tokens = this.get();
if (tokens && tokens.expires_at < Date.now()) {
this.clear();
}
}
}
// 页面加载时清理过期令牌
new SecureTokenStorage().cleanup();
<?php
// 服务器端加密存储
class SecureTokenStorage {
private $encryptionKey;
public function __construct() {
$this->encryptionKey = getenv('TOKEN_ENCRYPTION_KEY');
}
public function save($userId, $tokens) {
$encryptedAccess = $this->encrypt($tokens['access_token']);
$encryptedRefresh = $this->encrypt($tokens['refresh_token']);
$stmt = $this->pdo->prepare('
INSERT INTO user_tokens (user_id, access_token, refresh_token, expires_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
access_token = VALUES(access_token),
refresh_token = VALUES(refresh_token),
expires_at = VALUES(expires_at)
');
$stmt->execute([
$userId,
$encryptedAccess,
$encryptedRefresh,
date('Y-m-d H:i:s', time() + $tokens['expires_in'])
]);
}
private function encrypt($data) {
$iv = random_bytes(16);
$encrypted = openssl_encrypt($data, 'AES-256-CBC', $this->encryptionKey, 0, $iv);
return base64_encode($iv . $encrypted);
}
private function decrypt($data) {
$data = base64_decode($data);
$iv = substr($data, 0, 16);
$encrypted = substr($data, 16);
return openssl_decrypt($encrypted, 'AES-256-CBC', $this->encryptionKey, 0, $iv);
}
}
?>
// 正确:使用Authorization头
fetch('/api/userinfo', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
// 错误:在URL中传递令牌
fetch(`/api/userinfo?token=${accessToken}`); // 危险!会泄露到日志
// 错误:在请求体中传递令牌
fetch('/api/userinfo', {
method: 'POST',
body: JSON.stringify({ token: accessToken }) // 不推荐
});
只请求应用必需的权限:
// 错误:请求过多权限
const scope = 'openid profile email read write admin';
// 正确:只请求必要权限
const scope = 'openid profile email';
| 作用域 | 权限 | 使用场景 |
|---|---|---|
| openid | 获取用户唯一标识 | 仅需登录 |
| profile | 获取用户名、昵称 | 显示用户信息 |
| 获取邮箱地址 | 需要联系用户 | |
| read | 读取用户数据 | 需要访问用户资源 |
| write | 修改用户数据 | 需要修改用户资源 |
先请求基本权限,需要时再请求更多:
// 初始登录:只请求基本信息
const basicScope = 'openid profile';
// 需要邮箱时:请求email权限
const emailScope = 'openid profile email';
// 需要访问数据时:请求read权限
const dataScope = 'openid profile email read';
问题: 未验证redirect_uri,可能导致开放重定向漏洞
解决: 严格验证redirect_uri
<?php
// 验证redirect_uri
function validateRedirectUri($redirectUri, $allowedUris) {
$parsed = parse_url($redirectUri);
foreach ($allowedUris as $allowed) {
$allowedParsed = parse_url($allowed);
// 检查协议、域名、端口、路径
if ($parsed['scheme'] === $allowedParsed['scheme'] &&
$parsed['host'] === $allowedParsed['host'] &&
($parsed['port'] ?? null) === ($allowedParsed['port'] ?? null) &&
strpos($parsed['path'], $allowedParsed['path']) === 0) {
return true;
}
}
return false;
}
?>
问题: 令牌泄露到日志、URL、错误信息
解决: 避免令牌出现在不安全位置
// 不要在日志中记录令牌
console.log('User logged in'); // 正确
console.log('Token:', accessToken); // 错误!
// 不要在URL中传递令牌
history.pushState({}, '', '/dashboard'); // 正确
history.pushState({}, '', `/dashboard?token=${accessToken}`); // 错误!
问题: 使用LocalStorage存储令牌
解决: 使用更安全的存储方式
// 不推荐
localStorage.setItem('access_token', token);
// 推荐
sessionStorage.setItem('access_token', token);
// 最佳(服务器端)
// 存储在数据库中,使用加密
// 发现令牌泄露后立即执行
async function handleTokenLeak() {
// 1. 清除本地令牌
tokenManager.clearTokens();
// 2. 通知服务器撤销令牌(如果有撤销端点)
// await revokeToken(leakedToken);
// 3. 强制用户重新登录
window.location.href = '/login?reason=token_leaked';
}
// 检测异常行为
class SecurityMonitor {
static checkSuspiciousActivity(userInfo) {
const warnings = [];
// 检查登录地点变化
if (this.isLocationChanged(userInfo.last_login_ip)) {
warnings.push('Login from new location');
}
// 检查登录时间异常
if (this.isUnusualTime()) {
warnings.push('Login at unusual time');
}
// 检查多次失败尝试
if (this.hasMultipleFailures()) {
warnings.push('Multiple failed attempts');
}
return warnings;
}
}