复查修复(四): 独立审查发现的问题
- S1/S2: 会话锁改 RLock(持锁可重入调 _save_sessions);cookie 时间戳改整数+解析兼容(会话创建/验证往返已实测) - M1: api_login 接入 verify_user(账号锁定/失败计数生效),DB 无用户时才回退 config 凭据 - M3+L5: handler _do_auth 统一入口加 IP 白名单检查;未知 auth_type 返回 401 - M4+L10: metrics 与无版本 /api/admin/ 加入受保护端点 - M6: debug 日志敏感头脱敏;main.py 不再打印 token 前缀 - M7: auth_token.txt / auth_sessions.json chmod 600 - M9: verify_user 统一错误消息防用户枚举 - M10: AdminAPI 复用共享 APIAuthManager(修复会话状态分裂) - L7: check_auth 大小写不敏感匹配(防 /API/.. 大写绕过) - L8: token_expires_at 显式 is not None 判断 - L11: verify_password 对非 bcrypt 哈希回退 PBKDF2(重写,修复 ValueError 分支不落回退的问题)
This commit is contained in:
+5
-1
@@ -16,7 +16,11 @@ class AdminAPI:
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.auth_manager = APIAuthManager(config)
|
||||
# 复用共享认证管理器(router 注入的 config['_auth_manager']),
|
||||
# 避免多实例各自持有会话表导致状态分裂
|
||||
self.auth_manager = config.get('_auth_manager') or APIAuthManager(config)
|
||||
if config.get('_auth_manager') is None:
|
||||
config['_auth_manager'] = self.auth_manager
|
||||
|
||||
def handle_request(self, handler, method, path, query_params):
|
||||
"""处理管理员API请求"""
|
||||
|
||||
@@ -3315,11 +3315,12 @@ class APIv2(APIv1):
|
||||
config_user = config.get('auth_user', '')
|
||||
config_pass = config.get('auth_pass', '')
|
||||
|
||||
# 数据库验证
|
||||
if db:
|
||||
user = db.get_user_with_password(username)
|
||||
if user and db.verify_password(password, user['password_hash']):
|
||||
# 数据库验证成功,生成 token
|
||||
# 数据库验证(走 verify_user: 含账号锁定/失败计数/启用检查)
|
||||
if db and db.get_user(username):
|
||||
result = db.verify_user(username, password)
|
||||
if result.get('valid'):
|
||||
user = result['user']
|
||||
# 验证成功,生成 token
|
||||
import secrets
|
||||
token = secrets.token_hex(32)
|
||||
token_expires_at = time.time() + 86400 # 24小时过期
|
||||
@@ -3337,6 +3338,14 @@ class APIv2(APIv1):
|
||||
"level": user.get('role', 'admin')
|
||||
})
|
||||
return
|
||||
else:
|
||||
# 账号锁定/禁用/密码错误:记录失败并返回,不回退到 config 凭据
|
||||
db.add_login_log(username, client_ip, 'failed', result.get('reason', '验证失败'))
|
||||
handler.send_json_response({
|
||||
"success": False,
|
||||
"error": result.get('reason', '用户名或密码错误')
|
||||
}, 401)
|
||||
return
|
||||
|
||||
# 配置文件验证(仅当数据库中没有该用户时)
|
||||
if username == config_user and password == config_pass:
|
||||
|
||||
+707
-698
File diff suppressed because it is too large
Load Diff
@@ -20,23 +20,6 @@ def get_resource_path(relative_path: str) -> str:
|
||||
return os.path.join(sys._MEIPASS, relative_path)
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), relative_path)
|
||||
|
||||
# 打包模式
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
# 外部目录:与 exe 同级
|
||||
external_path = os.path.join(os.path.dirname(sys.executable), relative_path)
|
||||
if os.path.exists(external_path):
|
||||
return external_path
|
||||
|
||||
# 打包后的资源路径(_MEIPASS)
|
||||
bundled_path = os.path.join(sys._MEIPASS, relative_path)
|
||||
if os.path.exists(bundled_path):
|
||||
return bundled_path
|
||||
|
||||
return external_path
|
||||
|
||||
# 开发模式
|
||||
return os.path.join(project_root, relative_path)
|
||||
|
||||
|
||||
def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
+26
-18
@@ -1258,7 +1258,8 @@ class DatabaseManager:
|
||||
user = session.query(UserRecord).filter_by(username=username).first()
|
||||
|
||||
if not user:
|
||||
return {'valid': False, 'reason': '用户不存在'}
|
||||
# 统一错误消息,防止用户枚举
|
||||
return {'valid': False, 'reason': '用户名或密码错误'}
|
||||
|
||||
# 检查是否被锁定
|
||||
if user.locked_until and user.locked_until > time.time():
|
||||
@@ -1341,26 +1342,33 @@ class DatabaseManager:
|
||||
f"${base64.b64encode(dk).decode()}")
|
||||
|
||||
def verify_password(self, password: str, password_hash: str) -> bool:
|
||||
"""验证密码是否正确"""
|
||||
"""验证密码是否正确(bcrypt 优先;非 bcrypt 哈希或 bcrypt 缺失时回退 PBKDF2,绝不接受明文)"""
|
||||
import hashlib
|
||||
import base64
|
||||
import hmac
|
||||
|
||||
# 1) 优先 bcrypt;ValueError(哈希不是 bcrypt 格式)与 ImportError 都继续走回退
|
||||
try:
|
||||
import bcrypt
|
||||
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
||||
except ImportError:
|
||||
# 回退: 只接受自描述 PBKDF2 格式,绝不接受明文
|
||||
import hashlib
|
||||
import base64
|
||||
import hmac
|
||||
if not password_hash.startswith('$pbkdf2-sha256$'):
|
||||
return False
|
||||
try:
|
||||
_, algo, iters_s, salt_b64, hash_b64 = password_hash.split('$')
|
||||
iterations = int(iters_s)
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(hash_b64)
|
||||
dk = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, iterations)
|
||||
return hmac.compare_digest(dk, expected)
|
||||
except Exception:
|
||||
return False
|
||||
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
||||
except ValueError:
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 2) 回退: 只接受自描述 PBKDF2 格式
|
||||
if not password_hash.startswith('$pbkdf2-sha256$'):
|
||||
return False
|
||||
try:
|
||||
_, algo, iters_s, salt_b64, hash_b64 = password_hash.split('$')
|
||||
iterations = int(iters_s)
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(hash_b64)
|
||||
dk = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, iterations)
|
||||
return hmac.compare_digest(dk, expected)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_user_by_token(self, token: str) -> dict:
|
||||
"""通过 token 获取用户信息"""
|
||||
|
||||
+51
-12
@@ -282,6 +282,9 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
|
||||
method = (getattr(self, 'command', '') or 'GET').upper()
|
||||
|
||||
# 大小写不敏感匹配(仅用于鉴权决策,不影响文件路径处理)
|
||||
check_path = check_path.lower()
|
||||
|
||||
# 任何方法都公开的端点(登录、认证状态查询等)
|
||||
public_any = [
|
||||
'api/v2/user/login',
|
||||
@@ -344,6 +347,10 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
# 管理控制台页面本身: 访问时浏览器弹 Basic 登录框,
|
||||
# 登录后同源 API 请求自动携带凭据, 面板才能工作
|
||||
'api/ui',
|
||||
# 无版本前缀的管理路由
|
||||
'api/admin/',
|
||||
# 系统指标(泄露主机资源信息)
|
||||
'api/v2/metrics',
|
||||
]
|
||||
|
||||
# 任何方法都公开
|
||||
@@ -372,11 +379,39 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
return True
|
||||
|
||||
def _do_auth(self, auth_type):
|
||||
"""执行指定类型的认证检查"""
|
||||
"""执行指定类型的认证检查(统一入口: 先过 IP 白名单)"""
|
||||
if not self._ip_allowed():
|
||||
self.send_auth_required()
|
||||
return False
|
||||
if auth_type == 'basic':
|
||||
return self._check_basic_auth()
|
||||
elif auth_type == 'token':
|
||||
return self._check_token_auth()
|
||||
# 未知 auth_type: 拒绝而非静默断连
|
||||
self.send_auth_required()
|
||||
return False
|
||||
|
||||
def _ip_allowed(self):
|
||||
"""检查客户端 IP 是否在白名单内(白名单未启用时放行)"""
|
||||
if not self.config.get('ip_whitelist_enabled', False):
|
||||
return True
|
||||
whitelist = self.config.get('ip_whitelist', []) or []
|
||||
if not whitelist:
|
||||
return True
|
||||
try:
|
||||
ip = self.client_address[0]
|
||||
except Exception:
|
||||
return False
|
||||
import ipaddress
|
||||
for pattern in whitelist:
|
||||
try:
|
||||
if '/' in str(pattern):
|
||||
if ipaddress.ip_address(ip) in ipaddress.ip_network(str(pattern), strict=False):
|
||||
return True
|
||||
elif str(pattern) == ip:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
def _check_basic_auth(self):
|
||||
@@ -454,7 +489,9 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
sys.stderr.flush()
|
||||
# 调试模式输出请求详情 (debug-http)
|
||||
if self._is_debug_enabled('http'):
|
||||
msg = f"\n=== DEBUG GET Request ===\n Path: {self.path}\n Headers: {dict(self.headers)}"
|
||||
safe_headers = {k: ('***' if k.lower() in ('authorization', 'x-api-key', 'cookie', 'token')
|
||||
else v) for k, v in self.headers.items()}
|
||||
msg = f"\n=== DEBUG GET Request ===\n Path: {self.path}\n Headers: {safe_headers}"
|
||||
self._debug_log('http', msg, '\033[33m')
|
||||
|
||||
try:
|
||||
@@ -724,7 +761,10 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
<body>
|
||||
<h1>{title}</h1>
|
||||
'''
|
||||
import html as _html
|
||||
for item in items:
|
||||
item['name'] = _html.escape(str(item['name']))
|
||||
item['path'] = _html.escape(str(item['path']), quote=True)
|
||||
if item['is_dir']:
|
||||
html += f'''
|
||||
<div class="item dir">
|
||||
@@ -1504,19 +1544,18 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
except Exception as e:
|
||||
print(f"Error logging download to database: {e}")
|
||||
|
||||
# 回退到 JSON 文件(加锁防并发写坏/丢记录)
|
||||
# 回退到 JSON 文件(读-改-写全程持同一把锁,防止并发丢记录)
|
||||
with self._stats_lock:
|
||||
history = self.load_download_history(1000)
|
||||
|
||||
entry = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'filepath': filepath,
|
||||
'file_size': file_size,
|
||||
'client_ip': client_ip,
|
||||
'user_agent': user_agent,
|
||||
'method': self.command if hasattr(self, 'command') else 'GET'
|
||||
}
|
||||
entry = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'filepath': filepath,
|
||||
'file_size': file_size,
|
||||
'client_ip': client_ip,
|
||||
'user_agent': user_agent,
|
||||
'method': self.command if hasattr(self, 'command') else 'GET'
|
||||
}
|
||||
|
||||
with self._stats_lock:
|
||||
history.append(entry)
|
||||
self.save_download_history(history)
|
||||
|
||||
@@ -373,19 +373,20 @@ def main():
|
||||
existing_token = f.read().strip()
|
||||
if existing_token:
|
||||
config['auth_token'] = existing_token
|
||||
print(f" 已从文件加载 auth_token: {config['auth_token'][:16]}...")
|
||||
print(" 已从文件加载 auth_token")
|
||||
except Exception as e:
|
||||
print(f" 警告: 读取 auth_token 失败: {e}")
|
||||
|
||||
# 如果没有现有 token,生成新的
|
||||
if not existing_token:
|
||||
config['auth_token'] = secrets.token_hex(32)
|
||||
print(f" 已生成新的 auth_token: {config['auth_token'][:16]}...")
|
||||
print(" 已生成新的 auth_token (保存在 auth_token.txt)")
|
||||
|
||||
# 保存新的 token 到文件
|
||||
# 保存新的 token 到文件(收紧权限)
|
||||
try:
|
||||
with open(token_file, 'w') as f:
|
||||
f.write(config['auth_token'])
|
||||
os.chmod(token_file, 0o600)
|
||||
print(f" 已保存 auth_token 到: {token_file}")
|
||||
except Exception as e:
|
||||
print(f" 警告: 保存 auth_token 失败: {e}")
|
||||
|
||||
Reference in New Issue
Block a user