Skip to content

Commit b57d3c4

Browse files
EstrellaXDclaude
andcommitted
feat(security): add security config UI and improve auth/MCP security
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a0bf878 commit b57d3c4

25 files changed

Lines changed: 1618 additions & 299 deletions

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,41 @@
1+
# [Unreleased]
2+
3+
## Backend
4+
5+
### Added
6+
7+
- 新增 `Security` 配置模型,支持登录 IP 白名单、MCP IP 白名单和 Bearer Token 认证
8+
- 新增登录端点 IP 白名单检查中间件 (`check_login_ip`)
9+
- MCP 安全中间件升级为可配置模式:支持 CIDR 白名单 + Bearer Token 双重认证
10+
- 认证端点支持 `Authorization: Bearer` 令牌绕过 Cookie 登录
11+
- 配置 API `_sanitize_dict` 修复:仅对字符串值进行脱敏,避免误处理非字符串字段
12+
13+
- 新增番剧放送日手动设置 API (`PATCH /api/v1/bangumi/{id}/weekday`),支持锁定放送日防止日历刷新覆盖
14+
- 数据库迁移 v9:`bangumi` 表新增 `weekday_locked`
15+
16+
### Changed
17+
18+
- 重构认证模块:提取 `_issue_token` 公共方法,消除 3 处重复的 JWT 签发逻辑
19+
- `get_current_user` 简化为三级认证(DEV 绕过 → Bearer Token → Cookie JWT)
20+
- `LocalNetworkMiddleware` 重命名为 `McpAccessMiddleware`,从硬编码 RFC 1918 改为读取配置
21+
22+
### Tests
23+
24+
- 新增 101 个单元测试覆盖安全、认证、配置、下载器和 MockDownloader 模块
25+
26+
## Frontend
27+
28+
### Added
29+
30+
- 新增日历拖拽排列功能:可将「未知」番剧拖入星期列,自动设置放送日并锁定
31+
- 拖入后显示紫色图钉图标,鼠标悬停显示取消按钮
32+
- 锁定的番剧在日历刷新时不会被覆盖
33+
- 使用 vuedraggable 实现流畅拖拽动画
34+
- 新增安全设置组件 (`config-security.vue`),支持在 WebUI 中配置 IP 白名单和 Token
35+
- 前端 `Security` 类型定义和初始化配置
36+
37+
---
38+
139
# [3.2.3] - 2026-02-23
240

341
## Backend

backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "auto-bangumi"
3-
version = "3.2.3"
3+
version = "3.2.4"
44
description = "AutoBangumi - Automated anime download manager"
55
requires-python = ">=3.13"
66
dependencies = [

backend/src/module/api/auth.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from module.security.api import (
1010
active_user,
1111
auth_user,
12+
check_login_ip,
1213
get_current_user,
1314
update_user_info,
1415
)
@@ -18,42 +19,49 @@
1819

1920
router = APIRouter(prefix="/auth", tags=["auth"])
2021

22+
_TOKEN_EXPIRY_DAYS = 1
23+
_TOKEN_MAX_AGE = 86400
2124

22-
@router.post("/login", response_model=dict)
25+
26+
def _issue_token(username: str, response: Response) -> dict:
27+
"""Create a JWT, set it as an HttpOnly cookie, and return the bearer payload."""
28+
token = create_access_token(
29+
data={"sub": username}, expires_delta=timedelta(days=_TOKEN_EXPIRY_DAYS)
30+
)
31+
response.set_cookie(key="token", value=token, httponly=True, max_age=_TOKEN_MAX_AGE)
32+
return {"access_token": token, "token_type": "bearer"}
33+
34+
35+
@router.post("/login", response_model=dict, dependencies=[Depends(check_login_ip)])
2336
async def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)):
37+
"""Authenticate with username/password and issue a session token."""
2438
user = User(username=form_data.username, password=form_data.password)
2539
resp = auth_user(user)
2640
if resp.status:
27-
token = create_access_token(
28-
data={"sub": user.username}, expires_delta=timedelta(days=1)
29-
)
30-
response.set_cookie(key="token", value=token, httponly=True, max_age=86400)
31-
return {"access_token": token, "token_type": "bearer"}
41+
return _issue_token(user.username, response)
3242
return u_response(resp)
3343

3444

3545
@router.get(
3646
"/refresh_token", response_model=dict, dependencies=[Depends(get_current_user)]
3747
)
3848
async def refresh(response: Response, token: str = Cookie(None)):
49+
"""Refresh the current session token and update the active-user timestamp."""
3950
payload = decode_token(token)
4051
username = payload.get("sub") if payload else None
4152
if not username:
4253
raise HTTPException(
4354
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
4455
)
4556
active_user[username] = datetime.now()
46-
new_token = create_access_token(
47-
data={"sub": username}, expires_delta=timedelta(days=1)
48-
)
49-
response.set_cookie(key="token", value=new_token, httponly=True, max_age=86400)
50-
return {"access_token": new_token, "token_type": "bearer"}
57+
return _issue_token(username, response)
5158

5259

5360
@router.get(
5461
"/logout", response_model=APIResponse, dependencies=[Depends(get_current_user)]
5562
)
5663
async def logout(response: Response, token: str = Cookie(None)):
64+
"""Invalidate the session and clear the token cookie."""
5765
payload = decode_token(token)
5866
username = payload.get("sub") if payload else None
5967
if username:
@@ -69,24 +77,12 @@ async def logout(response: Response, token: str = Cookie(None)):
6977
async def update_user(
7078
user_data: UserUpdate, response: Response, token: str = Cookie(None)
7179
):
80+
"""Update credentials for the current user and re-issue a fresh token."""
7281
payload = decode_token(token)
7382
old_user = payload.get("sub") if payload else None
7483
if not old_user:
7584
raise HTTPException(
7685
status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized"
7786
)
7887
if update_user_info(user_data, old_user):
79-
token = create_access_token(
80-
data={"sub": old_user}, expires_delta=timedelta(days=1)
81-
)
82-
response.set_cookie(
83-
key="token",
84-
value=token,
85-
httponly=True,
86-
max_age=86400,
87-
)
88-
return {
89-
"access_token": token,
90-
"token_type": "bearer",
91-
"message": "update success",
92-
}
88+
return {**_issue_token(old_user, response), "message": "update success"}

backend/src/module/api/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414

1515

1616
def _sanitize_dict(d: dict) -> dict:
17+
"""Recursively mask string values whose keys contain sensitive keywords."""
1718
result = {}
1819
for k, v in d.items():
1920
if isinstance(v, dict):
2021
result[k] = _sanitize_dict(v)
21-
elif any(s in k.lower() for s in _SENSITIVE_KEYS):
22+
elif isinstance(v, str) and any(s in k.lower() for s in _SENSITIVE_KEYS):
2223
result[k] = "********"
2324
else:
2425
result[k] = v
@@ -27,13 +28,15 @@ def _sanitize_dict(d: dict) -> dict:
2728

2829
@router.get("/get", dependencies=[Depends(get_current_user)])
2930
async def get_config():
31+
"""Return the current configuration with sensitive fields masked."""
3032
return _sanitize_dict(settings.dict())
3133

3234

3335
@router.patch(
3436
"/update", response_model=APIResponse, dependencies=[Depends(get_current_user)]
3537
)
3638
async def update_config(config: Config):
39+
"""Persist and reload configuration from the supplied payload."""
3740
try:
3841
settings.save(config_dict=config.dict())
3942
settings.load()

backend/src/module/conf/config.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from module.models.config import Config
99

10-
from .const import ENV_TO_ATTR
10+
from .const import DEFAULT_SETTINGS, ENV_TO_ATTR
1111

1212
logger = logging.getLogger(__name__)
1313
CONFIG_ROOT = Path("config")
@@ -27,6 +27,15 @@
2727

2828

2929
class Settings(Config):
30+
"""Runtime configuration singleton.
31+
32+
On construction, loads from ``CONFIG_PATH`` if the file exists (and
33+
immediately re-saves to apply any migrations), otherwise bootstraps
34+
defaults from environment variables via ``init()``.
35+
36+
Use ``settings`` module-level instance rather than instantiating directly.
37+
"""
38+
3039
def __init__(self):
3140
super().__init__()
3241
if CONFIG_PATH.exists():
@@ -36,6 +45,7 @@ def __init__(self):
3645
self.init()
3746

3847
def load(self):
48+
"""Load and validate configuration from ``CONFIG_PATH``, applying migrations."""
3949
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
4050
config = json.load(f)
4151
config = self._migrate_old_config(config)
@@ -65,20 +75,27 @@ def _migrate_old_config(config: dict) -> dict:
6575
for key in ("type", "custom_url", "token", "enable_tmdb"):
6676
rss_parser.pop(key, None)
6777

78+
# Add security section if missing (preserves local-network MCP default)
79+
if "security" not in config:
80+
config["security"] = DEFAULT_SETTINGS["security"]
81+
6882
return config
6983

7084
def save(self, config_dict: dict | None = None):
85+
"""Write configuration to ``CONFIG_PATH``. Uses current state when no dict supplied."""
7186
if not config_dict:
7287
config_dict = self.model_dump()
7388
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
7489
json.dump(config_dict, f, indent=4, ensure_ascii=False)
7590

7691
def init(self):
92+
"""Bootstrap a new config file from ``.env`` and environment variables."""
7793
load_dotenv(".env")
7894
self.__load_from_env()
7995
self.save()
8096

8197
def __load_from_env(self):
98+
"""Apply ``ENV_TO_ATTR`` mappings from the process environment to the config dict."""
8299
config_dict = self.model_dump()
83100
for key, section in ENV_TO_ATTR.items():
84101
for env, attr in section.items():
@@ -97,12 +114,11 @@ def __load_from_env(self):
97114
logger.info("Config loaded from env")
98115

99116
@staticmethod
100-
def __val_from_env(env: str, attr: tuple):
117+
def __val_from_env(env: str, attr: tuple | str):
118+
"""Return the environment variable value, applying the converter when attr is a tuple."""
101119
if isinstance(attr, tuple):
102-
conv_func = attr[1]
103-
return conv_func(os.environ[env])
104-
else:
105-
return os.environ[env]
120+
return attr[1](os.environ[env])
121+
return os.environ[env]
106122

107123
@property
108124
def group_rules(self):

backend/src/module/conf/const.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
# -*- encoding: utf-8 -*-
2+
# DEFAULT_SETTINGS: factory defaults written to config.json on first run.
3+
# ENV_TO_ATTR: maps AB_* environment variables to Config model attribute paths.
4+
# Values are either a string attr name, a (attr_name, converter) tuple, or a
5+
# list of such tuples when a single env var sets multiple attributes.
26
DEFAULT_SETTINGS = {
37
"program": {
48
"rss_time": 900,
@@ -46,6 +50,20 @@
4650
"model": "gpt-3.5-turbo",
4751
"deployment_id": "",
4852
},
53+
"security": {
54+
"login_whitelist": [],
55+
"login_tokens": [],
56+
"mcp_whitelist": [
57+
"127.0.0.0/8",
58+
"10.0.0.0/8",
59+
"172.16.0.0/12",
60+
"192.168.0.0/16",
61+
"::1/128",
62+
"fe80::/10",
63+
"fc00::/7",
64+
],
65+
"mcp_tokens": [],
66+
},
4967
}
5068

5169

@@ -99,8 +117,11 @@
99117

100118

101119
class BCOLORS:
120+
"""ANSI colour helpers for terminal output."""
121+
102122
@staticmethod
103123
def _(color: str, *args: str) -> str:
124+
"""Wrap *args* in the given ANSI colour code and reset at the end."""
104125
strings = [str(s) for s in args]
105126
return f"{color}{', '.join(strings)}{BCOLORS.ENDC}"
106127

backend/src/module/downloader/client/mock_downloader.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ def __init__(self):
2929
"rss_processing_enabled": True,
3030
"rss_refresh_interval": 30,
3131
}
32-
logger.info("[MockDownloader] Initialized")
32+
logger.debug("[MockDownloader] Initialized")
3333

3434
async def auth(self, retry=3) -> bool:
35-
logger.info("[MockDownloader] Auth successful (mocked)")
35+
logger.debug("[MockDownloader] Auth successful (mocked)")
3636
return True
3737

3838
async def logout(self):

backend/src/module/downloader/download_client.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,42 @@
1111

1212

1313
class DownloadClient(TorrentPath):
14+
"""Unified async download client.
15+
16+
Wraps qBittorrent, Aria2, or MockDownloader behind a common interface.
17+
Intended to be used as an async context manager; authentication is
18+
performed on ``__aenter__`` and the session is closed on ``__aexit__``.
19+
"""
20+
1421
def __init__(self):
1522
super().__init__()
1623
self.client = self.__getClient()
1724
self.authed = False
1825

1926
@staticmethod
2027
def __getClient():
21-
type = settings.downloader.type
28+
"""Instantiate the configured downloader client (qbittorrent | aria2 | mock)."""
29+
downloader_type = settings.downloader.type
2230
host = settings.downloader.host
2331
username = settings.downloader.username
2432
password = settings.downloader.password
2533
ssl = settings.downloader.ssl
26-
if type == "qbittorrent":
34+
if downloader_type == "qbittorrent":
2735
from .client.qb_downloader import QbDownloader
2836

2937
return QbDownloader(host, username, password, ssl)
30-
elif type == "aria2":
38+
elif downloader_type == "aria2":
3139
from .client.aria2_downloader import Aria2Downloader
3240

3341
return Aria2Downloader(host, username, password)
34-
elif type == "mock":
42+
elif downloader_type == "mock":
3543
from .client.mock_downloader import MockDownloader
3644

37-
logger.info("[Downloader] Using MockDownloader for local development")
45+
logger.debug("[Downloader] Using MockDownloader for local development")
3846
return MockDownloader()
3947
else:
40-
logger.error(f"[Downloader] Unsupported downloader type: {type}")
41-
raise Exception(f"Unsupported downloader type: {type}")
48+
logger.error("[Downloader] Unsupported downloader type: %s", downloader_type)
49+
raise Exception(f"Unsupported downloader type: {downloader_type}")
4250

4351
async def __aenter__(self):
4452
if not self.authed:
@@ -65,6 +73,7 @@ async def check_host(self):
6573
return await self.client.check_host()
6674

6775
async def init_downloader(self):
76+
"""Apply required qBittorrent RSS preferences and create the Bangumi category."""
6877
prefs = {
6978
"rss_auto_downloading_enabled": True,
7079
"rss_max_articles_per_feed": 500,
@@ -84,6 +93,7 @@ async def init_downloader(self):
8493
settings.downloader.path = self._join_path(prefs["save_path"], "Bangumi")
8594

8695
async def set_rule(self, data: Bangumi):
96+
"""Create or update a qBittorrent RSS auto-download rule for one bangumi entry."""
8797
data.rule_name = self._rule_name(data)
8898
data.save_path = self._gen_save_path(data)
8999
rule = {
@@ -145,6 +155,12 @@ async def resume_torrent(self, hashes: str):
145155
await self.client.torrents_resume(hashes)
146156

147157
async def add_torrent(self, torrent: Torrent | list, bangumi: Bangumi) -> bool:
158+
"""Download a torrent (or list of torrents) for the given bangumi entry.
159+
160+
Handles both magnet links and .torrent file URLs, fetching file bytes
161+
when necessary. Tags each torrent with ``ab:<bangumi_id>`` for later
162+
episode-offset lookup during rename.
163+
"""
148164
if not bangumi.save_path:
149165
bangumi.save_path = self._gen_save_path(bangumi)
150166
async with RequestContent() as req:

0 commit comments

Comments
 (0)