feat: separate subscription/manual IPs + master enable/disable toggle
Data model (separate storage): - login_allowlist_enabled: 'true'/'false' master switch - login_subscription_url: URL to fetch every 2h - login_subscription_ips: last fetch result (replaced entirely each refresh) - login_manual_ips: manually added (never overwritten by refresh) - login_allowed_ips: combined (used by auth for legacy compat) ip_allowlist_refresh.py: - Saves to login_subscription_ips (replace) + rebuilds login_allowed_ips auth_service.py: - Login check: only runs when LOGIN_ALLOWLIST_ENABLED is 'true' - Combines subscription_ips + manual_ips at auth time (no stale combined key needed) settings.py: - POST /ip-allowlist/toggle: quick enable/disable endpoint - GET /ip-allowlist: returns subscription_ips, manual_ips separately - POST /ip-allowlist: saves manual_ips + optional subscription_url + optional enabled settings.html: - Toggle switch: enable/disable with warning if list is empty - Green notice when enabled (warns about self-lockout risk) - Grey notice when disabled - Subscription IPs section: read-only, auto-refresh badge - Manual IPs section: deletable per-IP, clear all button Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+61
-32
@@ -341,7 +341,8 @@ async def parse_subscription(
|
||||
|
||||
class IpAllowlistSaveRequest(BaseModel):
|
||||
manual_ips: list[str] = Field(default_factory=list)
|
||||
subscription_url: Optional[str] = None # None = don't change; "" = clear
|
||||
subscription_url: Optional[str] = None # None = don't change; "" = clear
|
||||
enabled: Optional[bool] = None # None = don't change
|
||||
|
||||
|
||||
@router.post("/ip-allowlist", response_model=dict)
|
||||
@@ -351,18 +352,24 @@ async def set_ip_allowlist(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Save manual IPs and/or subscription URL. Triggers immediate re-fetch if URL changed."""
|
||||
"""Save manual IPs, subscription URL, and/or enabled toggle."""
|
||||
from server.config import settings as _settings
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
|
||||
# Save manual IPs
|
||||
# Toggle enabled/disabled
|
||||
if payload.enabled is not None:
|
||||
val = "true" if payload.enabled else "false"
|
||||
await repo.set("login_allowlist_enabled", val)
|
||||
_settings.LOGIN_ALLOWLIST_ENABLED = val
|
||||
|
||||
# Manual IPs
|
||||
manual_cleaned = [ip.strip() for ip in payload.manual_ips if ip.strip()]
|
||||
manual_val = ",".join(manual_cleaned)
|
||||
await repo.set("login_manual_ips", manual_val)
|
||||
_settings.LOGIN_MANUAL_IPS = manual_val
|
||||
|
||||
# Save subscription URL if provided
|
||||
# Subscription URL
|
||||
sub_url_changed = False
|
||||
if payload.subscription_url is not None:
|
||||
sub_url = payload.subscription_url.strip()
|
||||
@@ -370,58 +377,80 @@ async def set_ip_allowlist(
|
||||
_settings.LOGIN_SUBSCRIPTION_URL = sub_url
|
||||
sub_url_changed = bool(sub_url)
|
||||
|
||||
# If no subscription URL, just use manual IPs as the allowlist
|
||||
# If no subscription URL, rebuild combined from manual only
|
||||
if not _settings.LOGIN_SUBSCRIPTION_URL:
|
||||
await repo.set("login_allowed_ips", manual_val)
|
||||
await repo.set("login_subscription_ips", "")
|
||||
_settings.LOGIN_ALLOWED_IPS = manual_val
|
||||
_settings.LOGIN_SUBSCRIPTION_IPS = ""
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
from server.domain.models import AuditLog
|
||||
ip_address = request.client.host if request.client else ""
|
||||
enabled_str = "" if payload.enabled is None else f"; 白名单开关={'开' if payload.enabled else '关'}"
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="update_ip_allowlist",
|
||||
target_type="setting",
|
||||
detail=f"手动IP {len(manual_cleaned)} 条; 订阅URL {'已更新' if payload.subscription_url is not None else '未变'}",
|
||||
detail=f"手动IP {len(manual_cleaned)} 条{enabled_str}",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
|
||||
# Trigger immediate refresh if subscription URL is set
|
||||
# Trigger refresh if subscription URL is present
|
||||
if sub_url_changed or _settings.LOGIN_SUBSCRIPTION_URL:
|
||||
from server.background.ip_allowlist_refresh import _do_refresh
|
||||
import asyncio
|
||||
asyncio.create_task(_do_refresh())
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"manual_count": len(manual_cleaned),
|
||||
"subscription_url": _settings.LOGIN_SUBSCRIPTION_URL or "",
|
||||
}
|
||||
return {"success": True, "manual_count": len(manual_cleaned)}
|
||||
|
||||
|
||||
@router.post("/ip-allowlist/toggle", response_model=dict)
|
||||
async def toggle_ip_allowlist(
|
||||
request: Request,
|
||||
enabled: bool,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Quick toggle for allowlist enforcement (no IP list changes)."""
|
||||
from server.config import settings as _settings
|
||||
|
||||
val = "true" if enabled else "false"
|
||||
repo = SettingRepositoryImpl(db)
|
||||
await repo.set("login_allowlist_enabled", val)
|
||||
_settings.LOGIN_ALLOWLIST_ENABLED = val
|
||||
|
||||
audit_repo = AuditLogRepositoryImpl(db)
|
||||
from server.domain.models import AuditLog
|
||||
ip_address = request.client.host if request.client else ""
|
||||
await audit_repo.create(AuditLog(
|
||||
admin_username=admin.username, action="toggle_ip_allowlist",
|
||||
target_type="setting",
|
||||
detail=f"登录IP白名单: {'启用' if enabled else '关闭'}",
|
||||
ip_address=ip_address,
|
||||
))
|
||||
return {"success": True, "enabled": enabled}
|
||||
|
||||
|
||||
@router.post("/ip-allowlist/manual", response_model=dict)
|
||||
async def add_manual_ips(
|
||||
payload: IpAllowlistRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Append IPs to the manual list and trigger allowlist rebuild."""
|
||||
from server.config import settings as _settings
|
||||
|
||||
existing_raw = (_settings.LOGIN_MANUAL_IPS or "")
|
||||
existing = [ip.strip() for ip in existing_raw.split(",") if ip.strip()]
|
||||
existing = [ip.strip() for ip in (_settings.LOGIN_MANUAL_IPS or "").split(",") if ip.strip()]
|
||||
new_ips = [ip.strip() for ip in payload.ips if ip.strip()]
|
||||
merged = list(dict.fromkeys(existing + new_ips)) # dedup, preserve order
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
merged = list(dict.fromkeys(existing + new_ips))
|
||||
val = ",".join(merged)
|
||||
repo = SettingRepositoryImpl(db)
|
||||
await repo.set("login_manual_ips", val)
|
||||
_settings.LOGIN_MANUAL_IPS = val
|
||||
|
||||
from server.background.ip_allowlist_refresh import _do_refresh
|
||||
import asyncio
|
||||
asyncio.create_task(_do_refresh())
|
||||
|
||||
return {"success": True, "manual_ips": merged}
|
||||
|
||||
|
||||
@@ -431,13 +460,12 @@ async def remove_allowlist_ip(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove a single IP from the manual list and rebuild."""
|
||||
"""Remove one IP from the manual list and rebuild."""
|
||||
from server.config import settings as _settings
|
||||
|
||||
existing = [i.strip() for i in (_settings.LOGIN_MANUAL_IPS or "").split(",") if i.strip()]
|
||||
remaining = [i for i in existing if i != ip.strip()]
|
||||
val = ",".join(remaining)
|
||||
|
||||
repo = SettingRepositoryImpl(db)
|
||||
await repo.set("login_manual_ips", val)
|
||||
_settings.LOGIN_MANUAL_IPS = val
|
||||
@@ -445,30 +473,31 @@ async def remove_allowlist_ip(
|
||||
from server.background.ip_allowlist_refresh import _do_refresh
|
||||
import asyncio
|
||||
asyncio.create_task(_do_refresh())
|
||||
|
||||
return {"success": True, "removed": ip, "remaining": remaining}
|
||||
|
||||
|
||||
@router.get("/ip-allowlist", response_model=dict)
|
||||
async def get_ip_allowlist(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return current IP allowlist state including subscription URL and last refresh time."""
|
||||
"""Return full allowlist state."""
|
||||
from server.config import settings as _settings
|
||||
from server.background.ip_allowlist_refresh import get_last_refresh_time
|
||||
from server.background.ip_allowlist_refresh import get_last_refresh_time, get_subscription_count
|
||||
|
||||
combined_raw = _settings.LOGIN_ALLOWED_IPS or ""
|
||||
allowlist_on = (_settings.LOGIN_ALLOWLIST_ENABLED or "false").lower() in ("true","1","yes","on")
|
||||
sub_ips_raw = _settings.LOGIN_SUBSCRIPTION_IPS or ""
|
||||
manual_raw = _settings.LOGIN_MANUAL_IPS or ""
|
||||
combined = [ip.strip() for ip in combined_raw.split(",") if ip.strip()]
|
||||
manual = [ip.strip() for ip in manual_raw.split(",") if ip.strip()]
|
||||
sub_ips = [ip.strip() for ip in sub_ips_raw.split(",") if ip.strip()]
|
||||
manual_ips = [ip.strip() for ip in manual_raw.split(",") if ip.strip()]
|
||||
|
||||
return {
|
||||
"ips": combined,
|
||||
"manual_ips": manual,
|
||||
"count": len(combined),
|
||||
"enabled": bool(combined),
|
||||
"enabled": allowlist_on,
|
||||
"subscription_url": _settings.LOGIN_SUBSCRIPTION_URL or "",
|
||||
"subscription_ips": sub_ips,
|
||||
"manual_ips": manual_ips,
|
||||
"subscription_count": len(sub_ips),
|
||||
"manual_count": len(manual_ips),
|
||||
"total_count": len(sub_ips) + len(set(manual_ips) - set(sub_ips)),
|
||||
"last_refresh": get_last_refresh_time(),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user