Skip to content

Commit 65034ec

Browse files
ZhuLinsen#602 [PR 3] 认证安全与初始化流程 (Auth & Security) (ZhuLinsen#639)
* feat(auth): add runtime auth settings endpoint * feat(auth): robust runtime auth settings and security hardening - Added '/api/v1/auth/settings' to enable/disable Web authentication at runtime - Implemented session secret rotation (HMAC invalidation) on any auth toggle - Fixed TOCTOU race condition in settings update via mandatory session validation - Added comprehensive integration tests for auth re-enablement and rate limiting - Improved credential persistence: added 'ENV_FILE' support and atomic 'replace' operations - Refactored auth initialization flow to prevent password overwrites and ensure rollback on failure * fix(auth): harden auth settings race checks * fix(auth): share config writer and propagate rotation failures * fix(auth): order toggle persistence and document worker scope * fix(auth): enhance error handling and logging for auth toggle application --------- Co-authored-by: mumu <42829555+ZhuLinsen@users.noreply.github.com>
1 parent 99fa425 commit 65034ec

8 files changed

Lines changed: 884 additions & 97 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444

4545
> 历史报告详情会优先展示 AI 返回的原始「狙击点位」文本,避免区间价、条件说明等复杂内容在历史回看时被压缩成单个数字。
4646
47+
> Web 管理认证支持运行时开关;如果系统中已保留管理员密码,重新开启认证时必须提供当前密码,避免在认证关闭窗口内直接获取新的管理员会话。
48+
> 多进程/多 worker 部署时,认证开关仅在当前进程即时生效;需重启或滚动重启全部 worker 以统一状态。
49+
4750
### 技术栈与数据来源
4851

4952
| 类型 | 支持 |
@@ -294,7 +297,7 @@ LITELLM_MODEL=openai/deepseek-chat
294297

295298
包含完整的配置管理、任务监控和手动分析功能。
296299

297-
**可选密码保护**:在 `.env` 中设置 `ADMIN_AUTH_ENABLED=true` 可启用 Web 登录,首次访问在网页设置初始密码,保护 Settings 中的 API 密钥等敏感配置。详见 [完整指南](docs/full-guide.md)
300+
**可选密码保护**:在 `.env` 中设置 `ADMIN_AUTH_ENABLED=true` 可启用 Web 登录,首次访问在网页设置初始密码,保护 Settings 中的 API 密钥等敏感配置。系统设置现支持运行时开启或关闭认证;关闭认证不会删除已保存密码,后续可直接重新启用。详见 [完整指南](docs/full-guide.md)
298301

299302
### 智能导入
300303

api/v1/endpoints/auth.py

Lines changed: 251 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from fastapi.responses import JSONResponse, Response
1111
from pydantic import BaseModel, Field
1212

13+
from api.deps import get_system_config_service
1314
from src.auth import (
1415
COOKIE_NAME,
1516
SESSION_MAX_AGE_HOURS_DEFAULT,
@@ -18,14 +19,20 @@
1819
clear_rate_limit,
1920
create_session,
2021
get_client_ip,
22+
has_stored_password,
2123
is_auth_enabled,
2224
is_password_changeable,
2325
is_password_set,
2426
record_login_failure,
27+
refresh_auth_state,
28+
rotate_session_secret,
2529
set_initial_password,
2630
verify_password,
31+
verify_stored_password,
2732
verify_session,
2833
)
34+
from src.config import Config, setup_env
35+
from src.core.config_manager import ConfigManager
2936

3037
logger = logging.getLogger(__name__)
3138

@@ -51,6 +58,17 @@ class ChangePasswordRequest(BaseModel):
5158
new_password_confirm: str = Field(default="", alias="newPasswordConfirm")
5259

5360

61+
class AuthSettingsRequest(BaseModel):
62+
"""Update auth enablement and initial password settings."""
63+
64+
model_config = {"populate_by_name": True}
65+
66+
auth_enabled: bool = Field(alias="authEnabled")
67+
password: str = Field(default="")
68+
password_confirm: str | None = Field(default=None, alias="passwordConfirm")
69+
current_password: str = Field(default="", alias="currentPassword")
70+
71+
5472
def _cookie_params(request: Request) -> dict:
5573
"""Build cookie params including Secure based on request."""
5674
secure = False
@@ -76,6 +94,66 @@ def _cookie_params(request: Request) -> dict:
7694
}
7795

7896

97+
def _apply_auth_enabled(enabled: bool, request: Request | None = None) -> bool:
98+
"""Persist auth toggle to .env and reload runtime config."""
99+
manager_applied = False
100+
if request is not None:
101+
try:
102+
service = get_system_config_service(request)
103+
service.apply_simple_updates(
104+
updates=[("ADMIN_AUTH_ENABLED", "true" if enabled else "false")],
105+
mask_token="******",
106+
)
107+
manager_applied = True
108+
except Exception as exc:
109+
logger.warning(
110+
"Failed to apply auth toggle via shared SystemConfigService, falling back: %s",
111+
exc,
112+
exc_info=True,
113+
)
114+
manager_applied = False
115+
116+
if not manager_applied:
117+
try:
118+
manager = ConfigManager()
119+
manager.apply_updates(
120+
updates=[("ADMIN_AUTH_ENABLED", "true" if enabled else "false")],
121+
sensitive_keys=set(),
122+
mask_token="******",
123+
)
124+
manager_applied = True
125+
except Exception as exc:
126+
logger.error("Failed to apply auth toggle via ConfigManager: %s", exc, exc_info=True)
127+
manager_applied = False
128+
129+
if not manager_applied:
130+
return False
131+
132+
Config.reset_instance()
133+
setup_env(override=True)
134+
refresh_auth_state()
135+
return True
136+
137+
138+
def _password_set_for_response(auth_enabled: bool) -> bool:
139+
"""Avoid exposing stored-password state when auth is disabled."""
140+
return is_password_set() if auth_enabled else False
141+
142+
143+
def _set_session_cookie(response: Response, session_value: str, request: Request) -> None:
144+
"""Attach the admin session cookie to a response."""
145+
params = _cookie_params(request)
146+
response.set_cookie(
147+
key=COOKIE_NAME,
148+
value=session_value,
149+
httponly=params["httponly"],
150+
samesite=params["samesite"],
151+
secure=params["secure"],
152+
path=params["path"],
153+
max_age=params["max_age"],
154+
)
155+
156+
79157
@router.get(
80158
"/status",
81159
summary="Get auth status",
@@ -91,11 +169,182 @@ async def auth_status(request: Request):
91169
return {
92170
"authEnabled": auth_enabled,
93171
"loggedIn": logged_in,
94-
"passwordSet": is_password_set() if auth_enabled else False,
172+
"passwordSet": _password_set_for_response(auth_enabled),
95173
"passwordChangeable": is_password_changeable() if auth_enabled else False,
96174
}
97175

98176

177+
@router.post(
178+
"/settings",
179+
summary="Update auth settings",
180+
description=(
181+
"Enable or disable password login. When enabling without an existing password, "
182+
"password + passwordConfirm are required. When re-enabling with a stored password, "
183+
"currentPassword is required."
184+
),
185+
)
186+
async def auth_update_settings(request: Request, body: AuthSettingsRequest):
187+
"""Manage auth enablement from the settings page."""
188+
target_enabled = body.auth_enabled
189+
current_enabled = is_auth_enabled()
190+
stored_password_exists = has_stored_password()
191+
192+
password = (body.password or "").strip()
193+
confirm = (body.password_confirm or "").strip()
194+
current_password = (body.current_password or "").strip()
195+
196+
if target_enabled:
197+
if password or confirm:
198+
if stored_password_exists:
199+
return JSONResponse(
200+
status_code=400,
201+
content={
202+
"error": "password_already_set",
203+
"message": "已存在管理员密码,请启用认证后通过修改密码功能更新",
204+
},
205+
)
206+
if not password:
207+
return JSONResponse(
208+
status_code=400,
209+
content={"error": "password_required", "message": "请输入要设置的管理员密码"},
210+
)
211+
if password != confirm:
212+
return JSONResponse(
213+
status_code=400,
214+
content={"error": "password_mismatch", "message": "两次输入的密码不一致"},
215+
)
216+
if has_stored_password():
217+
return JSONResponse(
218+
status_code=400,
219+
content={
220+
"error": "password_already_set",
221+
"message": "已存在管理员密码,请启用认证后通过修改密码功能更新",
222+
},
223+
)
224+
err = set_initial_password(password)
225+
if err:
226+
return JSONResponse(
227+
status_code=400,
228+
content={"error": "invalid_password", "message": err},
229+
)
230+
elif not stored_password_exists:
231+
return JSONResponse(
232+
status_code=400,
233+
content={"error": "password_required", "message": "开启密码登录前请先设置密码"},
234+
)
235+
else:
236+
# P1 Vulnerability Fix: Enforce current-password check independent of global cached flag
237+
# We must verify they actually possess a valid admin session, otherwise an attacker
238+
# could hit a race condition when auth becomes enabled mid-flight.
239+
# This triggers whenever trying to enable/keep enabled an existing auth setup.
240+
cookie_val = request.cookies.get(COOKIE_NAME)
241+
# if target_enabled is True here, they are requesting to enable or keep auth enabled
242+
is_valid_session = cookie_val and verify_session(cookie_val)
243+
244+
if not is_valid_session:
245+
if not current_password:
246+
return JSONResponse(
247+
status_code=400,
248+
content={"error": "current_required", "message": "重新开启认证前请输入当前密码"},
249+
)
250+
ip = get_client_ip(request)
251+
if not check_rate_limit(ip):
252+
return JSONResponse(
253+
status_code=429,
254+
content={
255+
"error": "rate_limited",
256+
"message": "Too many failed attempts. Please try again later.",
257+
},
258+
)
259+
if not verify_stored_password(current_password):
260+
record_login_failure(ip)
261+
return JSONResponse(
262+
status_code=401,
263+
content={"error": "invalid_password", "message": "当前密码错误"},
264+
)
265+
clear_rate_limit(ip)
266+
else:
267+
if current_enabled:
268+
cookie_val = request.cookies.get(COOKIE_NAME)
269+
is_valid_session = cookie_val and verify_session(cookie_val)
270+
271+
if not is_valid_session:
272+
if not current_password:
273+
return JSONResponse(
274+
status_code=400,
275+
content={"error": "current_required", "message": "关闭认证前请输入当前密码"},
276+
)
277+
ip = get_client_ip(request)
278+
if not check_rate_limit(ip):
279+
return JSONResponse(
280+
status_code=429,
281+
content={
282+
"error": "rate_limited",
283+
"message": "Too many failed attempts. Please try again later.",
284+
},
285+
)
286+
if not verify_stored_password(current_password):
287+
record_login_failure(ip)
288+
return JSONResponse(
289+
status_code=401,
290+
content={"error": "invalid_password", "message": "当前密码错误"},
291+
)
292+
clear_rate_limit(ip)
293+
294+
if target_enabled != current_enabled:
295+
if not _apply_auth_enabled(target_enabled, request=request):
296+
return JSONResponse(
297+
status_code=500,
298+
content={"error": "internal_error", "message": "Failed to update auth settings"},
299+
)
300+
if not rotate_session_secret():
301+
rollback_ok = _apply_auth_enabled(current_enabled, request=request)
302+
if not rollback_ok:
303+
logger.error("Failed to roll back auth state after session secret rotation failure")
304+
return JSONResponse(
305+
status_code=500,
306+
content={"error": "internal_error", "message": "Failed to rotate session secret"},
307+
)
308+
else:
309+
if not _apply_auth_enabled(target_enabled, request=request):
310+
return JSONResponse(
311+
status_code=500,
312+
content={"error": "internal_error", "message": "Failed to update auth settings"},
313+
)
314+
315+
if target_enabled:
316+
session_val = create_session()
317+
if not session_val:
318+
rollback_ok = _apply_auth_enabled(current_enabled, request=request)
319+
if not rollback_ok:
320+
logger.error("Failed to roll back auth state after session creation failure")
321+
return JSONResponse(
322+
status_code=500,
323+
content={"error": "internal_error", "message": "Failed to create session"},
324+
)
325+
resp = JSONResponse(
326+
content={
327+
"authEnabled": True,
328+
"loggedIn": True,
329+
"passwordSet": _password_set_for_response(True),
330+
"passwordChangeable": True,
331+
}
332+
)
333+
_set_session_cookie(resp, session_val, request)
334+
return resp
335+
336+
resp = JSONResponse(
337+
content={
338+
"authEnabled": False,
339+
"loggedIn": False,
340+
"passwordSet": _password_set_for_response(False),
341+
"passwordChangeable": False,
342+
}
343+
)
344+
resp.delete_cookie(key=COOKIE_NAME, path="/")
345+
return resp
346+
347+
99348
@router.post(
100349
"/login",
101350
summary="Login or set initial password",
@@ -161,16 +410,7 @@ async def auth_login(request: Request, body: LoginRequest):
161410
)
162411

163412
resp = JSONResponse(content={"ok": True})
164-
params = _cookie_params(request)
165-
resp.set_cookie(
166-
key=COOKIE_NAME,
167-
value=session_val,
168-
httponly=params["httponly"],
169-
samesite=params["samesite"],
170-
secure=params["secure"],
171-
path=params["path"],
172-
max_age=params["max_age"],
173-
)
413+
_set_session_cookie(resp, session_val, request)
174414
return resp
175415

176416

apps/dsa-web/src/api/auth.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,31 @@ export const authApi = {
1313
return data;
1414
},
1515

16+
async updateSettings(
17+
authEnabled: boolean,
18+
password?: string,
19+
passwordConfirm?: string,
20+
currentPassword?: string
21+
): Promise<AuthStatusResponse> {
22+
const body: {
23+
authEnabled: boolean;
24+
password?: string;
25+
passwordConfirm?: string;
26+
currentPassword?: string;
27+
} = { authEnabled };
28+
if (password !== undefined) {
29+
body.password = password;
30+
}
31+
if (passwordConfirm !== undefined) {
32+
body.passwordConfirm = passwordConfirm;
33+
}
34+
if (currentPassword !== undefined) {
35+
body.currentPassword = currentPassword;
36+
}
37+
const { data } = await apiClient.post<AuthStatusResponse>('/api/v1/auth/settings', body);
38+
return data;
39+
},
40+
1641
async login(password: string, passwordConfirm?: string): Promise<void> {
1742
const body: { password: string; passwordConfirm?: string } = { password };
1843
if (passwordConfirm !== undefined) {

docs/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1010
## [Unreleased]
1111

1212
### Added
13+
- 🔐 **Auth settings API** — new `POST /api/v1/auth/settings` endpoint to enable or disable Web authentication at runtime and set the initial admin password when needed
14+
15+
### Changed
16+
- 🔐 **Auth password state semantics** — stored password existence is now tracked independently from auth enablement; when auth is disabled, `/api/v1/auth/status` returns `passwordSet=false` while preserving the saved password for future re-enable
17+
- 🔐 **Auth settings re-enable hardening** — re-enabling auth with a stored password now requires `currentPassword`, and failed session creation rolls back the auth toggle to avoid lockout
18+
19+
### Fixed
20+
- 🐛 **Session secret rotation on Windows** — use atomic replace so auth toggles invalidate existing sessions even when `.session_secret` already exists
21+
- 🐛 **Auth toggle atomicity** — persist `ADMIN_AUTH_ENABLED` before rotating session secret; on rotation failure, roll back to the previous auth state
1322
- openclaw Skill 集成指南 — 新增 [docs/openclaw-skill-integration.md](openclaw-skill-integration.md),说明如何通过 openclaw Skill 调用 DSA API
1423
- ⚙️ **LLM channel protocol/test UX**`.env` and Web settings now share the same channel shape (`LLM_CHANNELS` + `LLM_<NAME>_PROTOCOL/BASE_URL/API_KEY/MODELS/ENABLED`); settings page adds per-channel connection testing, primary/fallback/vision model selection, and protocol-aware model prefixing
1524

@@ -18,6 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1827
- 🐛 **P0 基本面聚合稳定性修复** (#614) — 修复 `get_stock_info` 板块语义回归(新增 `belong_boards` 并保留 `boards` 兼容别名)、引入基本面上下文精简返回以控制 token、为基本面缓存增加最大条目淘汰,并补齐 ETF 总体状态聚合与 NaN 板块字段过滤,保证 fail-open 与最小入侵。
1928
- 🔧 **GitHub Actions 搜索引擎环境变量补充** — 工作流新增 `MINIMAX_API_KEYS``BRAVE_API_KEYS``SEARXNG_BASE_URLS` 环境变量映射,使 GitHub Actions 用户可配置 MiniMax、Brave、SearXNG 搜索服务(此前 v3.5.0 已添加 provider 实现但缺少工作流配置)
2029

30+
### Notes
31+
- ⚠️ **Multi-worker auth toggles** — runtime auth updates are process-local; multi-worker deployments must restart/roll workers to keep auth state consistent
32+
2133
## [3.5.0] - 2026-03-12
2234

2335
### Added

0 commit comments

Comments
 (0)