diff --git a/backend/app/gateway/auth/config.py b/backend/app/gateway/auth/config.py index 4734f0897..27c1984f1 100644 --- a/backend/app/gateway/auth/config.py +++ b/backend/app/gateway/auth/config.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, Field logger = logging.getLogger(__name__) +_SECRET_FILE = ".jwt_secret" + class AuthConfig(BaseModel): """JWT and auth-related configuration. Parsed once at startup. @@ -30,6 +32,32 @@ class AuthConfig(BaseModel): _auth_config: AuthConfig | None = None +def _load_or_create_secret() -> str: + """Load persisted JWT secret from ``{base_dir}/.jwt_secret``, or generate and persist a new one.""" + from deerflow.config.paths import get_paths + + paths = get_paths() + secret_file = paths.base_dir / _SECRET_FILE + + try: + if secret_file.exists(): + secret = secret_file.read_text(encoding="utf-8").strip() + if secret: + return secret + except OSError as exc: + raise RuntimeError(f"Failed to read JWT secret from {secret_file}. Set AUTH_JWT_SECRET explicitly or fix DEER_FLOW_HOME/base directory permissions so DeerFlow can read its persisted auth secret.") from exc + + secret = secrets.token_urlsafe(32) + try: + secret_file.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(secret_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(secret) + except OSError as exc: + raise RuntimeError(f"Failed to persist JWT secret to {secret_file}. Set AUTH_JWT_SECRET explicitly or fix DEER_FLOW_HOME/base directory permissions so DeerFlow can store a stable auth secret.") from exc + return secret + + def get_auth_config() -> AuthConfig: """Get the global AuthConfig instance. Parses from env on first call.""" global _auth_config @@ -39,11 +67,11 @@ def get_auth_config() -> AuthConfig: load_dotenv() jwt_secret = os.environ.get("AUTH_JWT_SECRET") if not jwt_secret: - jwt_secret = secrets.token_urlsafe(32) + jwt_secret = _load_or_create_secret() os.environ["AUTH_JWT_SECRET"] = jwt_secret logger.warning( - "⚠ AUTH_JWT_SECRET is not set — using an auto-generated ephemeral secret. " - "Sessions will be invalidated on restart. " + "⚠ AUTH_JWT_SECRET is not set — using an auto-generated secret " + "persisted to .jwt_secret. Sessions will survive restarts. " "For production, add AUTH_JWT_SECRET to your .env file: " 'python -c "import secrets; print(secrets.token_urlsafe(32))"' ) diff --git a/backend/docs/AUTH_UPGRADE.md b/backend/docs/AUTH_UPGRADE.md index 75fe8b3cb..b54283d24 100644 --- a/backend/docs/AUTH_UPGRADE.md +++ b/backend/docs/AUTH_UPGRADE.md @@ -99,7 +99,7 @@ rm -f backend/.deer-flow/data/deerflow.db | `.deer-flow/users/{user_id}/memory.json` | 用户级 memory | | `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory | | `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) | -| `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成临时密钥,重启后 session 失效) | +| `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成并持久化到 `.deer-flow/.jwt_secret`,重启后 session 保持) | ### 生产环境建议 @@ -137,4 +137,4 @@ python -c "import secrets; print(secrets.token_urlsafe(32))" | 启动后没看到密码 | 当前实现不在启动日志输出密码 | 首次安装访问 `/setup`;忘记密码用 `reset_admin` | | `/login` 自动跳到 `/setup` | 系统还没有 admin | 在 `/setup` 创建第一个 admin | | 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 | -| 重启后需要重新登录 | `AUTH_JWT_SECRET` 未持久化 | 在 `.env` 中设置固定密钥 | +| 重启后需要重新登录 | `.jwt_secret` 文件被删除且 `.env` 未设置 `AUTH_JWT_SECRET` | 在 `.env` 中设置固定密钥 | diff --git a/backend/tests/test_auth_config.py b/backend/tests/test_auth_config.py index 21b8bd81b..61d1d7d2e 100644 --- a/backend/tests/test_auth_config.py +++ b/backend/tests/test_auth_config.py @@ -5,28 +5,26 @@ from unittest.mock import patch import pytest -from app.gateway.auth.config import AuthConfig +import app.gateway.auth.config as cfg def test_auth_config_defaults(): - config = AuthConfig(jwt_secret="test-secret-key-123") + config = cfg.AuthConfig(jwt_secret="test-secret-key-123") assert config.token_expiry_days == 7 def test_auth_config_token_expiry_range(): - AuthConfig(jwt_secret="s", token_expiry_days=1) - AuthConfig(jwt_secret="s", token_expiry_days=30) + cfg.AuthConfig(jwt_secret="s", token_expiry_days=1) + cfg.AuthConfig(jwt_secret="s", token_expiry_days=30) with pytest.raises(Exception): - AuthConfig(jwt_secret="s", token_expiry_days=0) + cfg.AuthConfig(jwt_secret="s", token_expiry_days=0) with pytest.raises(Exception): - AuthConfig(jwt_secret="s", token_expiry_days=31) + cfg.AuthConfig(jwt_secret="s", token_expiry_days=31) def test_auth_config_from_env(): env = {"AUTH_JWT_SECRET": "test-jwt-secret-from-env"} with patch.dict(os.environ, env, clear=False): - import app.gateway.auth.config as cfg - old = cfg._auth_config cfg._auth_config = None try: @@ -36,19 +34,57 @@ def test_auth_config_from_env(): cfg._auth_config = old -def test_auth_config_missing_secret_generates_ephemeral(caplog): +def test_auth_config_missing_secret_generates_and_persists(tmp_path, caplog): import logging - import app.gateway.auth.config as cfg + from deerflow.config.paths import Paths old = cfg._auth_config cfg._auth_config = None + secret_file = tmp_path / ".jwt_secret" try: with patch.dict(os.environ, {}, clear=True): os.environ.pop("AUTH_JWT_SECRET", None) - with caplog.at_level(logging.WARNING): + with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=tmp_path)), caplog.at_level(logging.WARNING): config = cfg.get_auth_config() assert config.jwt_secret assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages) + assert secret_file.exists() + assert secret_file.read_text().strip() == config.jwt_secret + finally: + cfg._auth_config = old + + +def test_auth_config_reuses_persisted_secret(tmp_path): + from deerflow.config.paths import Paths + + old = cfg._auth_config + cfg._auth_config = None + persisted = "persisted-secret-from-file-min-32-chars!!" + (tmp_path / ".jwt_secret").write_text(persisted, encoding="utf-8") + try: + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("AUTH_JWT_SECRET", None) + with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=tmp_path)): + config = cfg.get_auth_config() + assert config.jwt_secret == persisted + finally: + cfg._auth_config = old + + +def test_auth_config_empty_secret_file_generates_new(tmp_path): + from deerflow.config.paths import Paths + + old = cfg._auth_config + cfg._auth_config = None + (tmp_path / ".jwt_secret").write_text("", encoding="utf-8") + try: + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("AUTH_JWT_SECRET", None) + with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=tmp_path)): + config = cfg.get_auth_config() + assert config.jwt_secret + assert len(config.jwt_secret) > 20 + assert (tmp_path / ".jwt_secret").read_text().strip() == config.jwt_secret finally: cfg._auth_config = old