1010from fastapi .responses import JSONResponse , Response
1111from pydantic import BaseModel , Field
1212
13+ from api .deps import get_system_config_service
1314from src .auth import (
1415 COOKIE_NAME ,
1516 SESSION_MAX_AGE_HOURS_DEFAULT ,
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
3037logger = 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+
5472def _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
0 commit comments