前言
在将PDF翻译能力以API形式开放给客户端或第三方系统时,安全鉴权和速率限制是两个必须解决的核心问题。没有鉴权,任何人都可以滥用你的API;没有限流,一个异常客户端就可能耗尽服务端资源,导致服务不可用。
本文从实战角度出发,介绍如何为PDF翻译API设计一套基于JWT(JSON Web Token)的鉴权体系,以及基于令牌桶(Token Bucket)算法的速率限制机制。文末提供完整的Python实现代码,可直接集成到现有项目中。
为什么PDF翻译API需要特别的安全设计
PDF翻译API与其他REST API相比,有两个特殊的安全风险点:
- 文件上传风险:客户端上传的PDF文件可能携带恶意payload(如超大文件导致内存溢出、嵌套JS导致PDF解析漏洞)
- 计算资源密集:翻译过程涉及AI模型推理,单次请求消耗的计算资源远高于普通CRUD操作,更容易被DDoS攻击放大影响
因此,PDF翻译API的安全设计需要同时覆盖认证层 (谁可以调用)和资源层(调用频率和用量限制)。
设计目标
我们的安全体系需要满足以下要求:
| 维度 | 要求 | 技术方案 |
|---|---|---|
| 身份认证 | 确认调用者身份 | JWT签名验证 |
| 权限控制 | 区分用户等级(普通/高级) | JWT Payload中的role字段 |
| 速率限制 | 防止单用户过度消耗 | 令牌桶算法 |
| 用量控制 | 限制月度翻译页数 | 计数器 + 数据库持久化 |
| 防重放 | 防止请求被截获重放 | JWT过期时间 + nonce校验 |
JWT鉴权设计
JWT结构
PDF翻译API的JWT包含以下自定义字段:
json
{
"sub": "user_12345",
"role": "premium",
"quota_pages": 1000,
"iat": 1723425600,
"exp": 1726017600,
"jti": "nonce_abc123"
}
| 字段 | 含义 | 说明 |
|---|---|---|
| sub | 用户唯一标识 | 用于关联用量统计 |
| role | 用户等级 | standard / premium / enterprise |
| quota_pages | 月度页数配额 | 本月剩余可翻译页数 |
| iat | 签发时间 | Unix时间戳 |
| exp | 过期时间 | 建议设置30天 |
| jti | 唯一标识 | 防重放攻击的nonce |
服务端验证流程
python
import jwt
from datetime import datetime, timezone
from functools import wraps
from flask import request, jsonify
import redis
class JWTAuthManager:
"""JWT鉴权管理器"""
def __init__(self, secret_key: str, redis_client: redis.Redis):
self.secret_key = secret_key
self.redis = redis_client
def verify_token(self, token: str) -> dict:
"""验证JWT令牌并返回payload"""
try:
payload = jwt.decode(
token,
self.secret_key,
algorithms=['HS256']
)
# 检查是否已被撤销(登出或权限变更)
jti = payload.get('jti')
if jti and self.redis.get(f"revoked:{jti}"):
raise jwt.InvalidTokenError("Token has been revoked")
# 检查是否已使用过(防重放)
if jti and self.redis.get(f"used:{jti}"):
raise jwt.InvalidTokenError("Token replay detected")
# 标记该jti已使用(有效期与token exp一致)
if jti:
exp = payload.get('exp', 0)
ttl = max(exp - int(datetime.now(timezone.utc).timestamp()), 60)
self.redis.setex(f"used:{jti}", ttl, "1")
return payload
except jwt.ExpiredSignatureError:
raise ValueError("Token expired")
except jwt.InvalidTokenError as e:
raise ValueError(f"Invalid token: {e}")
def generate_token(self, user_id: str, role: str,
quota_pages: int, expires_days: int = 30) -> str:
"""生成JWT令牌"""
now = datetime.now(timezone.utc)
payload = {
'sub': user_id,
'role': role,
'quota_pages': quota_pages,
'iat': int(now.timestamp()),
'exp': int((now.timestamp()) + expires_days * 86400),
'jti': f"{user_id}_{int(now.timestamp() * 1000)}"
}
return jwt.encode(payload, self.secret_key, algorithm='HS256')
def require_auth(auth_manager: JWTAuthManager):
"""Flask装饰器:要求JWT认证"""
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return jsonify({'error': 'Missing Authorization header'}), 401
token = auth_header.split(' ')[1]
try:
request.current_user = auth_manager.verify_token(token)
except ValueError as e:
return jsonify({'error': str(e)}), 401
return f(*args, **kwargs)
return wrapper
return decorator
速率限制设计
令牌桶算法
令牌桶是API限流最常用且最灵活的算法,它允许突发流量,同时保持长期平均速率稳定。
python
import time
from threading import Lock
class TokenBucket:
"""内存级令牌桶(单机版)"""
def __init__(self, rate: float, capacity: int):
"""
Args:
rate: 每秒产生的令牌数
capacity: 桶容量(最大突发请求数)
"""
self.rate = rate
self.capacity = capacity
self.tokens = capacity
self.last_update = time.time()
self.lock = Lock()
def consume(self, tokens: int = 1) -> bool:
"""尝试消费令牌,返回是否成功"""
with self.lock:
now = time.time()
elapsed = now - self.last_update
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.rate
)
self.last_update = now
if self.tokens >= tokens:
self.tokens -= tokens
return True
return False
class RedisTokenBucket:
"""Redis分布式令牌桶(多实例共享)"""
def __init__(self, redis_client: redis.Redis,
rate: float, capacity: int):
self.redis = redis_client
self.rate = rate
self.capacity = capacity
def consume(self, key: str, tokens: int = 1) -> bool:
"""使用Redis Lua脚本实现原子性令牌消费"""
lua_script = """
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local tokens = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('hmget', key, 'tokens', 'last_update')
local current_tokens = tonumber(bucket[1]) or capacity
local last_update = tonumber(bucket[2]) or now
local elapsed = now - last_update
local new_tokens = math.min(capacity, current_tokens + elapsed * rate)
if new_tokens >= tokens then
new_tokens = new_tokens - tokens
redis.call('hset', key, 'tokens', new_tokens, 'last_update', now)
redis.call('expire', key, 3600)
return 1
else
redis.call('hset', key, 'tokens', new_tokens, 'last_update', now)
redis.call('expire', key, 3600)
return 0
end
"""
result = self.redis.eval(
lua_script, 1, key,
self.rate, self.capacity, tokens, time.time()
)
return result == 1
多级限流策略
PDF翻译API建议实施三级限流:
python
class RateLimitManager:
"""多级速率限制管理器"""
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
# 按用户角色分配不同配额
self.buckets = {
'standard': RedisTokenBucket(redis_client, rate=0.1, capacity=3), # 6秒1请求
'premium': RedisTokenBucket(redis_client, rate=0.5, capacity=10), # 2秒1请求
'enterprise': RedisTokenBucket(redis_client, rate=2.0, capacity=30), # 0.5秒1请求
}
def check_rate_limit(self, user_id: str, role: str) -> tuple:
"""检查是否通过限流,返回(是否通过, 剩余配额, 重置时间)"""
bucket = self.buckets.get(role, self.buckets['standard'])
# 请求级限流(每秒请求数)
if not bucket.consume(f"req:{user_id}", tokens=1):
return False, 0, 60
# 分钟级限流(每分钟请求数)
minute_key = f"min:{user_id}:{int(time.time()) // 60}"
minute_count = self.redis.incr(minute_key)
if minute_count == 1:
self.redis.expire(minute_key, 60)
minute_limits = {'standard': 10, 'premium': 60, 'enterprise': 300}
if minute_count > minute_limits.get(role, 10):
return False, 0, 60 - int(time.time()) % 60
return True, minute_limits.get(role, 10) - minute_count, 60
完整Flask API集成示例
python
from flask import Flask, request, jsonify
import redis
from werkzeug.utils import secure_filename
import os
app = Flask(__name__)
# 初始化组件
redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
auth_manager = JWTAuthManager(secret_key="your-secret-key-here", redis_client=redis_client)
rate_limiter = RateLimitManager(redis_client=redis_client)
# 文件大小限制(10MB)
MAX_FILE_SIZE = 10 * 1024 * 1024
@app.route('/api/v1/auth/token', methods=['POST'])
def generate_token():
"""生成JWT令牌(仅管理员调用)"""
data = request.get_json()
user_id = data.get('user_id')
role = data.get('role', 'standard')
quota = data.get('quota_pages', 100)
token = auth_manager.generate_token(user_id, role, quota)
return jsonify({'token': token, 'expires_in': 30 * 86400})
@app.route('/api/v1/translate', methods=['POST'])
@require_auth(auth_manager)
def translate_pdf():
"""PDF翻译接口(带鉴权和限流)"""
user = request.current_user
user_id = user['sub']
role = user['role']
# 1. 速率限制检查
allowed, remaining, reset = rate_limiter.check_rate_limit(user_id, role)
if not allowed:
return jsonify({
'error': 'Rate limit exceeded',
'retry_after': reset
}), 429
# 2. 文件上传检查
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'Empty filename'}), 400
# 3. 文件大小检查
file.seek(0, os.SEEK_END)
size = file.tell()
file.seek(0)
if size > MAX_FILE_SIZE:
return jsonify({'error': 'File too large (max 10MB)'}), 413
# 4. 文件类型检查
filename = secure_filename(file.filename)
if not filename.endswith('.pdf'):
return jsonify({'error': 'Only PDF files allowed'}), 400
# 5. 页数配额检查
# 这里简化处理,实际应通过PDF解析获取真实页数
estimated_pages = max(1, size // (500 * 1024)) # 粗略估算:500KB/页
quota_key = f"quota:{user_id}:{datetime.now().strftime('%Y%m')}"
used_pages = int(redis_client.get(quota_key) or 0)
if used_pages + estimated_pages > user.get('quota_pages', 100):
return jsonify({
'error': 'Monthly quota exceeded',
'used': used_pages,
'quota': user.get('quota_pages')
}), 403
# 6. 执行翻译(调用实际翻译服务)
# 这里省略具体翻译逻辑,假设调用外部服务
try:
# result = pdf_translator_service.translate(file, ...)
# 更新用量
redis_client.incrby(quota_key, estimated_pages)
redis_client.expire(quota_key, 30 * 86400) # 30天后过期
return jsonify({
'status': 'success',
'message': 'Translation queued',
'remaining_quota': user.get('quota_pages') - used_pages - estimated_pages,
'rate_limit_remaining': remaining
})
except Exception as e:
return jsonify({'error': f'Translation failed: {str(e)}'}), 500
@app.route('/api/v1/quota', methods=['GET'])
@require_auth(auth_manager)
def get_quota():
"""查询当前用量配额"""
user = request.current_user
user_id = user['sub']
quota = user.get('quota_pages', 100)
quota_key = f"quota:{user_id}:{datetime.now().strftime('%Y%m')}"
used = int(redis_client.get(quota_key) or 0)
return jsonify({
'quota': quota,
'used': used,
'remaining': quota - used,
'role': user['role']
})
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)
安全测试清单
部署前,建议使用以下测试用例验证安全体系的健壮性:
| 测试项 | 方法 | 预期结果 |
|---|---|---|
| 无Token请求 | 不带Authorization头 | 401 Unauthorized |
| 过期Token | 使用已过期JWT | 401 Token expired |
| 篡改Token | 修改JWT payload后请求 | 401 Invalid signature |
| 重放攻击 | 同一Token重复请求 | 第二次401 Replay detected |
| 超速率 | 1秒内发送20个请求 | 429 Rate limit exceeded |
| 超大文件 | 上传15MB文件 | 413 File too large |
| 非PDF文件 | 上传.exe文件 | 400 Only PDF allowed |
| 超配额 | 超过月度页数限制 | 403 Quota exceeded |
总结
PDF翻译API的安全设计需要同时覆盖认证、授权、限流和用量控制四个维度。本文提供的JWT鉴权 + 令牌桶限流 + 配额管理方案,是生产环境中经过验证的有效组合:
- JWT鉴权:无状态、可携带用户角色和配额信息,适合分布式部署
- Redis分布式令牌桶:支持多实例共享限流状态,Lua脚本保证原子性
- 多级限流:请求级(秒)+ 分钟级 + 月度配额,从多个维度保护资源
- 防重放:通过jti字段和Redis标记,阻止Token截获重放攻击
如果你的项目正在考虑开放PDF翻译API,建议优先实施请求认证和速率限制,再根据业务需要逐步增加文件安全检查、内容审计和配额管理等高级功能。
标签:PDF翻译、API安全、JWT鉴权、速率限制、Python后端、Redis