agent-Specialization/server/auth.py

297 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import mimetypes
from pathlib import Path
from flask import Blueprint, request, jsonify, session, redirect, send_from_directory, abort, current_app
from modules.personalization_manager import load_personalization_config
from modules.user_manager import UserWorkspace
from config import (
TERMINAL_SANDBOX_MODE,
HOST_PROJECT_PATH,
DATA_DIR,
LOGS_DIR,
UPLOAD_QUARANTINE_SUBDIR,
)
from .auth_helpers import login_required, api_login_required, get_current_user_record, get_current_username
from .security import (
get_csrf_token,
check_rate_limit,
register_failure,
is_action_blocked,
clear_failures,
)
from .context import with_terminal, get_gui_manager
from . import state
from .utils_common import debug_log
auth_bp = Blueprint("auth", __name__)
@auth_bp.route('/api/csrf-token', methods=['GET'])
def issue_csrf_token():
token = get_csrf_token()
response = jsonify({"success": True, "token": token})
response.headers['Cache-Control'] = 'no-store'
return response
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'GET':
if session.get('username'):
return redirect('/new')
if not state.container_manager.has_capacity():
return current_app.send_static_file('resource_busy.html'), 503
return current_app.send_static_file('login.html')
data = request.get_json() or {}
email = (data.get('email') or '').strip()
password = data.get('password') or ''
client_ip = request.headers.get('X-Forwarded-For', '').split(',')[0].strip() or request.remote_addr or 'unknown'
limited, retry_after = check_rate_limit("login", 10, 60, client_ip)
if limited:
return jsonify({"success": False, "error": "登录请求过于频繁,请稍后再试。", "retry_after": retry_after}), 429
blocked, block_for = is_action_blocked("login", identifier=client_ip)
if blocked:
return jsonify({"success": False, "error": f"尝试次数过多,请 {block_for} 秒后重试。", "retry_after": block_for}), 429
record = state.user_manager.authenticate(email, password)
if not record:
wait_seconds = register_failure("login", state.FAILED_LOGIN_LIMIT, state.FAILED_LOGIN_LOCK_SECONDS, identifier=client_ip)
error_payload = {"success": False, "error": "账号或密码错误"}
status_code = 401
if wait_seconds:
error_payload.update({"error": f"尝试次数过多,请 {wait_seconds} 秒后重试。", "retry_after": wait_seconds})
status_code = 429
return jsonify(error_payload), status_code
workspace = state.user_manager.ensure_user_workspace(record.username)
preferred_run_mode = None
try:
personal_config = load_personalization_config(workspace.data_dir)
candidate_mode = (personal_config or {}).get('default_run_mode')
if isinstance(candidate_mode, str):
normalized_mode = candidate_mode.lower()
if normalized_mode in {"fast", "thinking", "deep"}:
preferred_run_mode = normalized_mode
except Exception as exc:
debug_log(f"加载个性化偏好失败: {exc}")
session['logged_in'] = True
session['username'] = record.username
session['role'] = record.role or 'user'
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
session['thinking_mode'] = default_thinking
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
if preferred_run_mode:
session['run_mode'] = preferred_run_mode
session['thinking_mode'] = preferred_run_mode != 'fast'
session.permanent = True
clear_failures("login", identifier=client_ip)
try:
state.container_manager.ensure_container(record.username, str(workspace.project_path))
except RuntimeError as exc:
session.clear()
return jsonify({"success": False, "error": str(exc), "code": "resource_busy"}), 503
from .usage import record_user_activity
record_user_activity(record.username)
get_csrf_token(force_new=True)
return jsonify({"success": True})
@auth_bp.route('/host-login', methods=['POST'])
def host_login():
"""宿主机模式一键进入(仅当 TERMINAL_SANDBOX_MODE=host 时可用)。"""
if (TERMINAL_SANDBOX_MODE or "").lower() != "host":
return jsonify({"success": False, "error": "宿主机模式未启用"}), 403
if not state.container_manager.has_capacity("host"):
return jsonify({"success": False, "error": "资源繁忙,请稍后再试"}), 503
host_path = Path(HOST_PROJECT_PATH).expanduser().resolve()
host_path.mkdir(parents=True, exist_ok=True)
data_dir = Path(DATA_DIR).expanduser().resolve()
data_dir.mkdir(parents=True, exist_ok=True)
logs_dir = Path(LOGS_DIR).expanduser().resolve()
logs_dir.mkdir(parents=True, exist_ok=True)
uploads_dir = host_path / "user_upload"
uploads_dir.mkdir(parents=True, exist_ok=True)
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
if not quarantine_root.is_absolute():
quarantine_root = (host_path.parent / UPLOAD_QUARANTINE_SUBDIR).resolve()
quarantine_root.mkdir(parents=True, exist_ok=True)
# 初始化 session跳过账号体系
session.clear()
session['logged_in'] = True
session['username'] = 'host'
session['role'] = 'admin'
session['host_mode'] = True
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
session['thinking_mode'] = default_thinking
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
session.permanent = True
# 预先创建宿主机模式的终端/容器句柄host 模式不会启动 Docker
try:
state.container_manager.ensure_container("host", str(host_path), container_key="host")
except RuntimeError as exc:
session.clear()
return jsonify({"success": False, "error": str(exc)}), 503
get_csrf_token(force_new=True)
return jsonify({"success": True})
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'GET':
if session.get('username'):
return redirect('/new')
return current_app.send_static_file('register.html')
data = request.get_json() or {}
username = (data.get('username') or '').strip()
email = (data.get('email') or '').strip()
password = data.get('password') or ''
invite_code = (data.get('invite_code') or '').strip()
from .security import get_client_ip
limited, retry_after = check_rate_limit("register", 5, 300, get_client_ip())
if limited:
return jsonify({"success": False, "error": "注册请求过于频繁,请稍后再试。", "retry_after": retry_after}), 429
try:
state.user_manager.register_user(username, email, password, invite_code)
return jsonify({"success": True})
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@auth_bp.route('/logout', methods=['POST'])
def logout():
username = session.get('username')
session.clear()
if username:
# 清理该用户相关的所有终端/容器(包含 API 多工作区)
term_keys = [k for k in list(state.user_terminals.keys()) if k == username or k.startswith(f"{username}::")]
for key in term_keys:
state.user_terminals.pop(key, None)
try:
state.container_manager.release_container(key, reason="logout")
except Exception:
pass
for token_value, meta in list(state.pending_socket_tokens.items()):
if meta.get("username") == username:
state.pending_socket_tokens.pop(token_value, None)
return jsonify({"success": True})
@auth_bp.route('/')
@login_required
def index():
return redirect('/new')
@auth_bp.route('/new')
@login_required
def new_page():
return current_app.send_static_file('index.html')
@auth_bp.route('/<conv:conversation_id>')
@login_required
def conversation_page(conversation_id):
return current_app.send_static_file('index.html')
@auth_bp.route('/terminal')
@login_required
def terminal_page():
from .auth_helpers import resolve_admin_policy
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_realtime_terminal"):
return "实时终端已被管理员禁用", 403
return current_app.send_static_file('terminal.html')
@auth_bp.route('/file-manager')
@login_required
def gui_file_manager_page():
from .auth_helpers import resolve_admin_policy
policy = resolve_admin_policy(get_current_user_record())
if policy.get("ui_blocks", {}).get("block_file_manager"):
return "文件管理器已被管理员禁用", 403
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'index.html')
@auth_bp.route('/file-manager/editor')
@login_required
def gui_file_editor_page():
return send_from_directory(Path(current_app.static_folder) / 'file_manager', 'editor.html')
@auth_bp.route('/file-preview/<path:relative_path>')
@login_required
@with_terminal
def gui_file_preview(relative_path: str, terminal, workspace: UserWorkspace, username: str):
manager = get_gui_manager(workspace)
try:
target = manager.prepare_download(relative_path)
if not target.is_file():
return "预览仅支持文件", 400
return send_from_directory(directory=target.parent, path=target.name, mimetype='text/html')
except Exception as exc:
return f"无法预览文件: {exc}", 400
@auth_bp.route('/user_upload/<path:filename>')
@login_required
def serve_user_upload(filename: str):
user = get_current_user_record()
if not user:
return redirect('/login')
workspace = state.user_manager.ensure_user_workspace(user.username)
uploads_dir = workspace.uploads_dir.resolve()
target = (uploads_dir / filename).resolve()
try:
target.relative_to(uploads_dir)
except ValueError:
abort(403)
if not target.exists() or not target.is_file():
abort(404)
return send_from_directory(str(uploads_dir), str(target.relative_to(uploads_dir)))
@auth_bp.route('/workspace/<path:filename>')
@login_required
def serve_workspace_file(filename: str):
user = get_current_user_record()
if not user:
return redirect('/login')
workspace = state.user_manager.ensure_user_workspace(user.username)
project_root = workspace.project_path.resolve()
target = (project_root / filename).resolve()
try:
target.relative_to(project_root)
except ValueError:
abort(403)
if not target.exists() or not target.is_file():
abort(404)
mime_type, _ = mimetypes.guess_type(str(target))
if not mime_type or not mime_type.startswith("image/"):
abort(415)
return send_from_directory(str(target.parent), target.name)
@auth_bp.route('/static/<path:filename>')
def static_files(filename):
if filename.startswith('admin_dashboard'):
abort(404)
return send_from_directory('static', filename)
__all__ = ["auth_bp"]