mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-23 00:16:48 +00:00
fix(auth): persist auto-generated JWT secret to survive restarts (#2933)
* fix(auth): persist auto-generated JWT secret to survive restarts When AUTH_JWT_SECRET is not set, the auto-generated secret is now written to .deer-flow/.jwt_secret (mode 0600) and reused on subsequent starts. This prevents session invalidation on every restart while still allowing explicit AUTH_JWT_SECRET in .env to take precedence. * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix the lint errors of backend --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SECRET_FILE = ".jwt_secret"
|
||||||
|
|
||||||
|
|
||||||
class AuthConfig(BaseModel):
|
class AuthConfig(BaseModel):
|
||||||
"""JWT and auth-related configuration. Parsed once at startup.
|
"""JWT and auth-related configuration. Parsed once at startup.
|
||||||
@@ -30,6 +32,32 @@ class AuthConfig(BaseModel):
|
|||||||
_auth_config: AuthConfig | None = None
|
_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:
|
def get_auth_config() -> AuthConfig:
|
||||||
"""Get the global AuthConfig instance. Parses from env on first call."""
|
"""Get the global AuthConfig instance. Parses from env on first call."""
|
||||||
global _auth_config
|
global _auth_config
|
||||||
@@ -39,11 +67,11 @@ def get_auth_config() -> AuthConfig:
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
jwt_secret = os.environ.get("AUTH_JWT_SECRET")
|
jwt_secret = os.environ.get("AUTH_JWT_SECRET")
|
||||||
if not 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
|
os.environ["AUTH_JWT_SECRET"] = jwt_secret
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"⚠ AUTH_JWT_SECRET is not set — using an auto-generated ephemeral secret. "
|
"⚠ AUTH_JWT_SECRET is not set — using an auto-generated secret "
|
||||||
"Sessions will be invalidated on restart. "
|
"persisted to .jwt_secret. Sessions will survive restarts. "
|
||||||
"For production, add AUTH_JWT_SECRET to your .env file: "
|
"For production, add AUTH_JWT_SECRET to your .env file: "
|
||||||
'python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
'python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}/memory.json` | 用户级 memory |
|
||||||
| `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory |
|
| `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory |
|
||||||
| `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) |
|
| `.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` |
|
| 启动后没看到密码 | 当前实现不在启动日志输出密码 | 首次安装访问 `/setup`;忘记密码用 `reset_admin` |
|
||||||
| `/login` 自动跳到 `/setup` | 系统还没有 admin | 在 `/setup` 创建第一个 admin |
|
| `/login` 自动跳到 `/setup` | 系统还没有 admin | 在 `/setup` 创建第一个 admin |
|
||||||
| 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 |
|
| 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 |
|
||||||
| 重启后需要重新登录 | `AUTH_JWT_SECRET` 未持久化 | 在 `.env` 中设置固定密钥 |
|
| 重启后需要重新登录 | `.jwt_secret` 文件被删除且 `.env` 未设置 `AUTH_JWT_SECRET` | 在 `.env` 中设置固定密钥 |
|
||||||
|
|||||||
@@ -5,28 +5,26 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.gateway.auth.config import AuthConfig
|
import app.gateway.auth.config as cfg
|
||||||
|
|
||||||
|
|
||||||
def test_auth_config_defaults():
|
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
|
assert config.token_expiry_days == 7
|
||||||
|
|
||||||
|
|
||||||
def test_auth_config_token_expiry_range():
|
def test_auth_config_token_expiry_range():
|
||||||
AuthConfig(jwt_secret="s", token_expiry_days=1)
|
cfg.AuthConfig(jwt_secret="s", token_expiry_days=1)
|
||||||
AuthConfig(jwt_secret="s", token_expiry_days=30)
|
cfg.AuthConfig(jwt_secret="s", token_expiry_days=30)
|
||||||
with pytest.raises(Exception):
|
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):
|
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():
|
def test_auth_config_from_env():
|
||||||
env = {"AUTH_JWT_SECRET": "test-jwt-secret-from-env"}
|
env = {"AUTH_JWT_SECRET": "test-jwt-secret-from-env"}
|
||||||
with patch.dict(os.environ, env, clear=False):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
import app.gateway.auth.config as cfg
|
|
||||||
|
|
||||||
old = cfg._auth_config
|
old = cfg._auth_config
|
||||||
cfg._auth_config = None
|
cfg._auth_config = None
|
||||||
try:
|
try:
|
||||||
@@ -36,19 +34,57 @@ def test_auth_config_from_env():
|
|||||||
cfg._auth_config = old
|
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 logging
|
||||||
|
|
||||||
import app.gateway.auth.config as cfg
|
from deerflow.config.paths import Paths
|
||||||
|
|
||||||
old = cfg._auth_config
|
old = cfg._auth_config
|
||||||
cfg._auth_config = None
|
cfg._auth_config = None
|
||||||
|
secret_file = tmp_path / ".jwt_secret"
|
||||||
try:
|
try:
|
||||||
with patch.dict(os.environ, {}, clear=True):
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
os.environ.pop("AUTH_JWT_SECRET", None)
|
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()
|
config = cfg.get_auth_config()
|
||||||
assert config.jwt_secret
|
assert config.jwt_secret
|
||||||
assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages)
|
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:
|
finally:
|
||||||
cfg._auth_config = old
|
cfg._auth_config = old
|
||||||
|
|||||||
Reference in New Issue
Block a user