安全指南

本文档详细说明接入 user.blym.top OAuth 2.0服务时的安全最佳实践,帮助您构建安全可靠的应用。

安全概述

OAuth 2.0是一个安全的授权协议,但实现不当可能导致安全漏洞。以下是关键安全要点:

┌─────────────────────────────────────────────────────────────────────────┐
│                         OAuth安全要点                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  1. 传输安全                                                            │
│     └─ 必须使用HTTPS                                                    │
│                                                                         │
│  2. State参数                                                           │
│     └─ 防止CSRF攻击                                                     │
│                                                                         │
│  3. PKCE扩展                                                            │
│     └─ 防止授权码拦截                                                   │
│                                                                         │
│  4. 令牌存储                                                            │
│     └─ 安全存储,防止泄露                                               │
│                                                                         │
│  5. 作用域最小化                                                        │
│     └─ 只请求必要的权限                                                 │
│                                                                         │
│  6. 令牌生命周期                                                        │
│     └─ 及时刷新,安全销毁                                               │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

传输安全

HTTPS要求

所有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防护

什么是CSRF攻击?

跨站请求伪造(CSRF)攻击者诱导用户在已登录状态下执行非预期操作。

在OAuth中,攻击者可能:

  1. 构造恶意授权链接
  2. 诱导用户点击
  3. 用户授权后,授权码被发送到攻击者控制的服务器

State参数防护

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参数实现

// 完整的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防护

什么是授权码拦截攻击?

攻击者可能:

  1. 拦截授权码
  2. 使用拦截的授权码换取令牌
  3. 获取用户资源

PKCE工作原理

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)             │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

PKCE完整实现

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 PKCE实现

<?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 获取用户名、昵称 显示用户信息
email 获取邮箱地址 需要联系用户
read 读取用户数据 需要访问用户资源
write 修改用户数据 需要修改用户资源

渐进式授权

先请求基本权限,需要时再请求更多:

// 初始登录:只请求基本信息
const basicScope = 'openid profile';

// 需要邮箱时:请求email权限
const emailScope = 'openid profile email';

// 需要访问数据时:请求read权限
const dataScope = 'openid profile email read';

安全检查清单

开发阶段

  • [ ] 所有OAuth通信使用HTTPS
  • [ ] 实现state参数验证
  • [ ] 公开客户端实现PKCE
  • [ ] 令牌安全存储(不用LocalStorage)
  • [ ] 只请求必要的作用域
  • [ ] 实现令牌刷新机制
  • [ ] 处理令牌过期情况

测试阶段

  • [ ] 测试state验证失败情况
  • [ ] 测试令牌过期处理
  • [ ] 测试刷新令牌过期处理
  • [ ] 测试用户拒绝授权情况
  • [ ] 测试网络错误处理
  • [ ] 进行安全漏洞扫描

部署阶段

  • [ ] 确保生产环境使用HTTPS
  • [ ] 配置正确的回调URL
  • [ ] 保护client_secret安全
  • [ ] 启用访问日志
  • [ ] 配置错误监控

运维阶段

  • [ ] 定期轮换client_secret
  • [ ] 监控异常授权行为
  • [ ] 及时撤销可疑令牌
  • [ ] 保持依赖库更新

常见安全问题

1. 开放重定向

问题: 未验证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;
}
?>

2. 令牌泄露

问题: 令牌泄露到日志、URL、错误信息

解决: 避免令牌出现在不安全位置

// 不要在日志中记录令牌
console.log('User logged in'); // 正确
console.log('Token:', accessToken); // 错误!

// 不要在URL中传递令牌
history.pushState({}, '', '/dashboard'); // 正确
history.pushState({}, '', `/dashboard?token=${accessToken}`); // 错误!

3. 不安全的客户端存储

问题: 使用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;
    }
}

下一步