Files
SenSu/services/web_panel/routes/auth.py
T
qinglong f7b4908322 security: 公网生产环境加固 — P0/P1 全部修复
API 认证 (16 个未保护端点 → 全部加 panel_auth):
- 文件管理器: 11 端点 (list/mkdir/delete/upload/download/read/write/...)
- 项目管理: 6 端点 (list/run/stop/logs/stdin/page), 修复硬编码路径前缀
- 代理管理: 3 端点 (list/add/remove)
- 系统状态: 2 HTTP + 1 WS (token 校验)
- 插件页面: 3 端点 (page/sse/event)
- 内部路由: 3 端点 (plugins/commands/data)

认证系统加固:
- 密码哈希: 固定盐 → 每用户独立 secrets.token_hex(16) 随机盐
- 默认密码警告: 启动时检测并打印 critical 级别日志
- 登录频率限制: 5 次失败 / IP → 锁定 60 秒, 返回 429

基础设施:
- 安全响应头: X-Content-Type-Options/X-Frame-Options/X-XSS-Protection/Referrer-Policy
- WebSocket 鉴权: query string token 校验

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-13 13:37:14 +08:00

126 lines
4.0 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import secrets
import time
import logging
from aiohttp import web
from ..utils.auth import panel_auth
logger = logging.getLogger(__name__)
# 全局 Session 存储 (内存型)
PANEL_SESSION_STORE = {}
# 登录频率限制 — {ip: [fail_count, lock_until_timestamp]}
_LOGIN_FAILS: dict[str, list] = {}
_MAX_FAILS = 5
_LOCK_SECONDS = 60
def _check_rate_limit(ip: str) -> bool:
"""检查 IP 是否被限流。返回 True = 允许尝试"""
now = time.time()
entry = _LOGIN_FAILS.get(ip)
if entry:
fail_count, lock_until = entry
if now < lock_until:
return False # still locked
if now >= lock_until + _LOCK_SECONDS:
_LOGIN_FAILS.pop(ip, None) # expired, reset
return True
def _record_fail(ip: str):
now = time.time()
entry = _LOGIN_FAILS.get(ip, [0, 0])
entry[0] += 1
if entry[0] >= _MAX_FAILS:
entry[1] = now + _LOCK_SECONDS
logger.warning(f"🔒 IP {ip} 登录锁定 {_LOCK_SECONDS}s ({_MAX_FAILS} 次失败)")
_LOGIN_FAILS[ip] = entry
def _clear_fails(ip: str):
_LOGIN_FAILS.pop(ip, None)
def setup_routes(app, prefix=''):
"""注册面板认证路由"""
# 🟢 关键:将 Session Store 挂载到 app,供拦截器读取
app['panel_session_store'] = PANEL_SESSION_STORE
# 路由注册
app.router.add_post(f'{prefix}/api/login', handle_login)
# 退出和状态检查都需要拦截
app.router.add_post(f'{prefix}/api/logout', panel_auth(handle_logout))
app.router.add_get(f'{prefix}/api/auth/status', panel_auth(handle_auth_status))
async def handle_login(req):
"""处理面板登录"""
try:
# 频率限制
ip = req.remote
if not _check_rate_limit(ip):
return web.json_response(
{"success": False, "msg": "尝试次数过多,请 60 秒后重试"},
status=429,
)
data = await req.json()
username = data.get('username')
password = data.get('password')
cfg = req.app.get('panel_config', {})
cfg_user = cfg.get('username', 'admin')
cfg_pass = cfg.get('password', 'admin')
# 校验配置中的账号密码
if username == cfg_user and password == cfg_pass:
_clear_fails(ip)
# 登录成功:生成 Token
token = secrets.token_hex(16)
# 写入 Session Store
user_info = {
"username": username,
"perms": ["admin"],
"login_time": __import__('time').time()
}
PANEL_SESSION_STORE[token] = user_info
logger.info(f"✅ 面板登录成功: {username} (Session: {token[:4]}...)")
resp = web.json_response({"success": True, "username": username})
# 设置 Cookie
resp.set_cookie("panel_token", token, max_age=259200, httponly=True, samesite="Lax")
return resp
else:
_record_fail(ip)
logger.warning(f"❌ 面板登录失败: 用户 {username} 密码错误 (IP: {ip})")
return web.json_response({"success": False, "msg": "用户名或密码错误"}, status=401)
except Exception as e:
logger.error(f"登录异常: {e}")
return web.json_response({"error": str(e)}, status=500)
async def handle_logout(req):
"""处理退出登录"""
token = req.cookies.get("panel_token")
if token and token in PANEL_SESSION_STORE:
del PANEL_SESSION_STORE[token]
logger.info(f"👋 用户退出登录")
resp = web.json_response({"success": True})
resp.del_cookie("panel_token")
return resp
async def handle_auth_status(req):
"""获取当前认证状态 (被 panel_auth 拦截,能进来说明已认证)"""
user = req.get('user', {})
return web.json_response({
"authenticated": True,
"username": user.get("username", "Unknown"),
"perms": user.get("perms", [])
})