复查修复(四): 独立审查发现的问题
- 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):
|
def __init__(self, config: dict):
|
||||||
self.config = config
|
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):
|
def handle_request(self, handler, method, path, query_params):
|
||||||
"""处理管理员API请求"""
|
"""处理管理员API请求"""
|
||||||
|
|||||||
@@ -3315,11 +3315,12 @@ class APIv2(APIv1):
|
|||||||
config_user = config.get('auth_user', '')
|
config_user = config.get('auth_user', '')
|
||||||
config_pass = config.get('auth_pass', '')
|
config_pass = config.get('auth_pass', '')
|
||||||
|
|
||||||
# 数据库验证
|
# 数据库验证(走 verify_user: 含账号锁定/失败计数/启用检查)
|
||||||
if db:
|
if db and db.get_user(username):
|
||||||
user = db.get_user_with_password(username)
|
result = db.verify_user(username, password)
|
||||||
if user and db.verify_password(password, user['password_hash']):
|
if result.get('valid'):
|
||||||
# 数据库验证成功,生成 token
|
user = result['user']
|
||||||
|
# 验证成功,生成 token
|
||||||
import secrets
|
import secrets
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
token_expires_at = time.time() + 86400 # 24小时过期
|
token_expires_at = time.time() + 86400 # 24小时过期
|
||||||
@@ -3337,6 +3338,14 @@ class APIv2(APIv1):
|
|||||||
"level": user.get('role', 'admin')
|
"level": user.get('role', 'admin')
|
||||||
})
|
})
|
||||||
return
|
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:
|
if username == config_user and password == config_pass:
|
||||||
|
|||||||
+16
-7
@@ -39,7 +39,7 @@ class APIAuthManager:
|
|||||||
def __init__(self, config: dict = None):
|
def __init__(self, config: dict = None):
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self.sessions: Dict[str, AuthSession] = {}
|
self.sessions: Dict[str, AuthSession] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.RLock() # 可重入: create/destroy 持锁时允许再调 _save_sessions
|
||||||
|
|
||||||
# 确定基础目录(用于保存会话文件)
|
# 确定基础目录(用于保存会话文件)
|
||||||
base_dir = config.get('base_dir', '.') if config else '.'
|
base_dir = config.get('base_dir', '.') if config else '.'
|
||||||
@@ -149,8 +149,8 @@ class APIAuthManager:
|
|||||||
if self.db:
|
if self.db:
|
||||||
user = self.db.get_user_by_token(token)
|
user = self.db.get_user_by_token(token)
|
||||||
if user:
|
if user:
|
||||||
# 检查 token 是否过期
|
# 检查 token 是否过期(显式 is not None,避免 0 被当作永不过期)
|
||||||
if user.get('token_expires_at') and time.time() > user['token_expires_at']:
|
if user.get('token_expires_at') is not None and time.time() > user['token_expires_at']:
|
||||||
return {"valid": False, "reason": "Token已过期"}
|
return {"valid": False, "reason": "Token已过期"}
|
||||||
|
|
||||||
# 检查用户是否启用
|
# 检查用户是否启用
|
||||||
@@ -224,10 +224,15 @@ class APIAuthManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
parts = cookie_value.split('.')
|
parts = cookie_value.split('.')
|
||||||
if len(parts) != 3:
|
if len(parts) < 3:
|
||||||
|
return None
|
||||||
|
# session_id(64 hex, 无点) 是固定的第一段; 其余段为 timestamp + signature
|
||||||
|
# (旧版 timestamp 为浮点含点, 兼容拆分后的多段)
|
||||||
|
session_id = parts[0]
|
||||||
|
timestamp = '.'.join(parts[1:-1])
|
||||||
|
signature = parts[-1]
|
||||||
|
if not timestamp or not signature:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
session_id, timestamp, signature = parts
|
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
session = self.sessions.get(session_id)
|
session = self.sessions.get(session_id)
|
||||||
@@ -383,7 +388,7 @@ class APIAuthManager:
|
|||||||
permissions: List[str] = None) -> dict:
|
permissions: List[str] = None) -> dict:
|
||||||
"""创建认证会话"""
|
"""创建认证会话"""
|
||||||
session_id = secrets.token_hex(32)
|
session_id = secrets.token_hex(32)
|
||||||
timestamp = time.time()
|
timestamp = int(time.time()) # 整数时间戳: cookie 用 . 分隔时不会把浮点拆成多段
|
||||||
|
|
||||||
session = AuthSession(
|
session = AuthSession(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -491,6 +496,10 @@ class APIAuthManager:
|
|||||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
os.replace(tmp_file, self.sessions_file)
|
os.replace(tmp_file, self.sessions_file)
|
||||||
|
try:
|
||||||
|
os.chmod(self.sessions_file, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"警告: 保存会话失败: {e}")
|
print(f"警告: 保存会话失败: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -20,23 +20,6 @@ def get_resource_path(relative_path: str) -> str:
|
|||||||
return os.path.join(sys._MEIPASS, relative_path)
|
return os.path.join(sys._MEIPASS, relative_path)
|
||||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 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]:
|
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()
|
user = session.query(UserRecord).filter_by(username=username).first()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
return {'valid': False, 'reason': '用户不存在'}
|
# 统一错误消息,防止用户枚举
|
||||||
|
return {'valid': False, 'reason': '用户名或密码错误'}
|
||||||
|
|
||||||
# 检查是否被锁定
|
# 检查是否被锁定
|
||||||
if user.locked_until and user.locked_until > time.time():
|
if user.locked_until and user.locked_until > time.time():
|
||||||
@@ -1341,26 +1342,33 @@ class DatabaseManager:
|
|||||||
f"${base64.b64encode(dk).decode()}")
|
f"${base64.b64encode(dk).decode()}")
|
||||||
|
|
||||||
def verify_password(self, password: str, password_hash: str) -> bool:
|
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:
|
try:
|
||||||
import bcrypt
|
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:
|
try:
|
||||||
_, algo, iters_s, salt_b64, hash_b64 = password_hash.split('$')
|
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
||||||
iterations = int(iters_s)
|
except ValueError:
|
||||||
salt = base64.b64decode(salt_b64)
|
pass
|
||||||
expected = base64.b64decode(hash_b64)
|
except ImportError:
|
||||||
dk = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, iterations)
|
pass
|
||||||
return hmac.compare_digest(dk, expected)
|
|
||||||
except Exception:
|
# 2) 回退: 只接受自描述 PBKDF2 格式
|
||||||
return False
|
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:
|
def get_user_by_token(self, token: str) -> dict:
|
||||||
"""通过 token 获取用户信息"""
|
"""通过 token 获取用户信息"""
|
||||||
|
|||||||
+51
-12
@@ -282,6 +282,9 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
method = (getattr(self, 'command', '') or 'GET').upper()
|
method = (getattr(self, 'command', '') or 'GET').upper()
|
||||||
|
|
||||||
|
# 大小写不敏感匹配(仅用于鉴权决策,不影响文件路径处理)
|
||||||
|
check_path = check_path.lower()
|
||||||
|
|
||||||
# 任何方法都公开的端点(登录、认证状态查询等)
|
# 任何方法都公开的端点(登录、认证状态查询等)
|
||||||
public_any = [
|
public_any = [
|
||||||
'api/v2/user/login',
|
'api/v2/user/login',
|
||||||
@@ -344,6 +347,10 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
|||||||
# 管理控制台页面本身: 访问时浏览器弹 Basic 登录框,
|
# 管理控制台页面本身: 访问时浏览器弹 Basic 登录框,
|
||||||
# 登录后同源 API 请求自动携带凭据, 面板才能工作
|
# 登录后同源 API 请求自动携带凭据, 面板才能工作
|
||||||
'api/ui',
|
'api/ui',
|
||||||
|
# 无版本前缀的管理路由
|
||||||
|
'api/admin/',
|
||||||
|
# 系统指标(泄露主机资源信息)
|
||||||
|
'api/v2/metrics',
|
||||||
]
|
]
|
||||||
|
|
||||||
# 任何方法都公开
|
# 任何方法都公开
|
||||||
@@ -372,11 +379,39 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _do_auth(self, auth_type):
|
def _do_auth(self, auth_type):
|
||||||
"""执行指定类型的认证检查"""
|
"""执行指定类型的认证检查(统一入口: 先过 IP 白名单)"""
|
||||||
|
if not self._ip_allowed():
|
||||||
|
self.send_auth_required()
|
||||||
|
return False
|
||||||
if auth_type == 'basic':
|
if auth_type == 'basic':
|
||||||
return self._check_basic_auth()
|
return self._check_basic_auth()
|
||||||
elif auth_type == 'token':
|
elif auth_type == 'token':
|
||||||
return self._check_token_auth()
|
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
|
return False
|
||||||
|
|
||||||
def _check_basic_auth(self):
|
def _check_basic_auth(self):
|
||||||
@@ -454,7 +489,9 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
|||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
# 调试模式输出请求详情 (debug-http)
|
# 调试模式输出请求详情 (debug-http)
|
||||||
if self._is_debug_enabled('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')
|
self._debug_log('http', msg, '\033[33m')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -724,7 +761,10 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
|||||||
<body>
|
<body>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
'''
|
'''
|
||||||
|
import html as _html
|
||||||
for item in items:
|
for item in items:
|
||||||
|
item['name'] = _html.escape(str(item['name']))
|
||||||
|
item['path'] = _html.escape(str(item['path']), quote=True)
|
||||||
if item['is_dir']:
|
if item['is_dir']:
|
||||||
html += f'''
|
html += f'''
|
||||||
<div class="item dir">
|
<div class="item dir">
|
||||||
@@ -1504,19 +1544,18 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error logging download to database: {e}")
|
print(f"Error logging download to database: {e}")
|
||||||
|
|
||||||
# 回退到 JSON 文件(加锁防并发写坏/丢记录)
|
# 回退到 JSON 文件(读-改-写全程持同一把锁,防止并发丢记录)
|
||||||
with self._stats_lock:
|
with self._stats_lock:
|
||||||
history = self.load_download_history(1000)
|
history = self.load_download_history(1000)
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
'timestamp': datetime.now().isoformat(),
|
'timestamp': datetime.now().isoformat(),
|
||||||
'filepath': filepath,
|
'filepath': filepath,
|
||||||
'file_size': file_size,
|
'file_size': file_size,
|
||||||
'client_ip': client_ip,
|
'client_ip': client_ip,
|
||||||
'user_agent': user_agent,
|
'user_agent': user_agent,
|
||||||
'method': self.command if hasattr(self, 'command') else 'GET'
|
'method': self.command if hasattr(self, 'command') else 'GET'
|
||||||
}
|
}
|
||||||
|
|
||||||
with self._stats_lock:
|
|
||||||
history.append(entry)
|
history.append(entry)
|
||||||
self.save_download_history(history)
|
self.save_download_history(history)
|
||||||
|
|||||||
@@ -373,19 +373,20 @@ def main():
|
|||||||
existing_token = f.read().strip()
|
existing_token = f.read().strip()
|
||||||
if existing_token:
|
if existing_token:
|
||||||
config['auth_token'] = existing_token
|
config['auth_token'] = existing_token
|
||||||
print(f" 已从文件加载 auth_token: {config['auth_token'][:16]}...")
|
print(" 已从文件加载 auth_token")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" 警告: 读取 auth_token 失败: {e}")
|
print(f" 警告: 读取 auth_token 失败: {e}")
|
||||||
|
|
||||||
# 如果没有现有 token,生成新的
|
# 如果没有现有 token,生成新的
|
||||||
if not existing_token:
|
if not existing_token:
|
||||||
config['auth_token'] = secrets.token_hex(32)
|
config['auth_token'] = secrets.token_hex(32)
|
||||||
print(f" 已生成新的 auth_token: {config['auth_token'][:16]}...")
|
print(" 已生成新的 auth_token (保存在 auth_token.txt)")
|
||||||
|
|
||||||
# 保存新的 token 到文件
|
# 保存新的 token 到文件(收紧权限)
|
||||||
try:
|
try:
|
||||||
with open(token_file, 'w') as f:
|
with open(token_file, 'w') as f:
|
||||||
f.write(config['auth_token'])
|
f.write(config['auth_token'])
|
||||||
|
os.chmod(token_file, 0o600)
|
||||||
print(f" 已保存 auth_token 到: {token_file}")
|
print(f" 已保存 auth_token 到: {token_file}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" 警告: 保存 auth_token 失败: {e}")
|
print(f" 警告: 保存 auth_token 失败: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user