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:
Your Name
2026-05-23 18:19:47 +08:00
parent da43960f38
commit 488838c989
5 changed files with 194 additions and 109 deletions
+61 -32
View File
@@ -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(),
}