From 177ec42416f53ee20ee44b539615be2fa81a8fe4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 24 Jun 2026 09:10:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=AF=86=E6=95=8F=E6=84=9F=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=B9=B6=E4=BC=98=E5=8C=96=E8=A1=A5CK=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + README.md | 8 + core/douyu/login.py | 33 ++- web/backend/crypto_storage.py | 191 ++++++++++++++++++ web/backend/database.py | 11 + .../20260623_0001_initial_web_schema.py | 16 +- .../20260624_0002_sensitive_columns_text.py | 51 +++++ web/backend/models.py | 19 +- web/backend/services/login_service.py | 4 +- 9 files changed, 315 insertions(+), 23 deletions(-) create mode 100644 web/backend/crypto_storage.py create mode 100644 web/backend/migrations/versions/20260624_0002_sensitive_columns_text.py diff --git a/.env.example b/.env.example index 880786f..6e44b74 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,11 @@ # 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))" JWT_SECRET_KEY= +# 敏感字段落盘加密密钥(生产环境必须设置且长期保持不变!) +# 用于加密账号密码、邮箱密码、Cookie、代理密钥等数据库字段 +# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))" +APP_ENCRYPTION_KEY= + # 默认管理员账号(仅首次启动建库时生效) ADMIN_USERNAME=admin ADMIN_PASSWORD=admin123 diff --git a/README.md b/README.md index 80df0c2..88ba1b7 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ cd web/frontend && npm install && npm run dev 访问 `http://localhost:5173`,默认账号 `admin / admin123`。 +### 敏感数据加密 + +账号密码、邮箱密码、Cookie、代理密钥等敏感字段会以密文形式落盘。生产环境请在 `.env` 中设置稳定的 `APP_ENCRYPTION_KEY`,并长期保留;更换该密钥会导致历史密文无法解密。 + +```bash +python -c "import secrets; print(secrets.token_urlsafe(32))" +``` + ## 角色权限 | 角色 | 权限 | diff --git a/core/douyu/login.py b/core/douyu/login.py index 86383ed..601d388 100644 --- a/core/douyu/login.py +++ b/core/douyu/login.py @@ -105,6 +105,7 @@ class DouyuLogin: self.proxy_manager = None self._current_proxy_url: Optional[str] = None + self._cookie_enrich_error = "" self._setup_session() def _setup_session(self) -> None: @@ -374,12 +375,15 @@ class DouyuLogin: # 7️⃣ 完成登录获取Cookie logger.info("步骤7: 完成登录,获取Cookie...") cookie = self._complete_login(login_url) + message = "登录成功" + if self._cookie_enrich_error: + message = f"登录成功,补CK失败: {self._cookie_enrich_error}" logger.success(f"登录成功! Cookie长度: {len(cookie)}") # 成功后归还代理到池,让其他账号复用 if self.proxy_manager and self._current_proxy_url: self.proxy_manager.release_proxy(self._current_proxy_url) - return LoginResult(success=True, cookie=cookie, message="登录成功") + return LoginResult(success=True, cookie=cookie, message=message) except Exception as e: elapsed = time.monotonic() - start_time @@ -736,12 +740,33 @@ class DouyuLogin: except Exception as e: logger.warning(f"WebLogin请求失败(不影响登录): {e}") - # 登录成功后补齐 Web 侧 Cookie,再导出完整 CK。 - self._generate_csrf_cookie() - self._generate_acf_ccn_cookie() + # 登录成功后补齐 Web 侧 Cookie。补 CK 失败只影响完整度,不回滚已成功的登录态。 + self._cookie_enrich_error = "" + try: + self._enrich_web_cookies_with_retry() + except Exception as e: + self._cookie_enrich_error = self._truncate_error(str(e), 160) + logger.warning(f"补CK最终失败,本次仍按登录成功保存基础CK: {self._cookie_enrich_error}") return self._format_cookie_string() + def _enrich_web_cookies_with_retry(self, max_attempts: int = 3) -> None: + """独立重试补齐 cvl_csrf_token 和 acf_ccn,不触发整条登录链路重跑。""" + last_error: Exception | None = None + for attempt in range(1, max_attempts + 1): + try: + logger.info(f"补CK尝试 {attempt}/{max_attempts}...") + self._generate_csrf_cookie() + self._generate_acf_ccn_cookie() + logger.info("补CK完成") + return + except Exception as e: + last_error = e + logger.warning(f"补CK失败 {attempt}/{max_attempts}: {e}") + if attempt < max_attempts: + time.sleep(1) + raise ValueError(f"已重试 {max_attempts} 次仍未补齐CK: {last_error}") from last_error + def _generate_csrf_cookie(self) -> str: """ 访问斗鱼 generateCsrf 接口,从 Set-Cookie 中同步 cvl_csrf_token。 diff --git a/web/backend/crypto_storage.py b/web/backend/crypto_storage.py new file mode 100644 index 0000000..9318c73 --- /dev/null +++ b/web/backend/crypto_storage.py @@ -0,0 +1,191 @@ +"""敏感字段落盘加密工具。""" + +from __future__ import annotations + +import base64 +import hashlib +import os +import warnings +from dataclasses import dataclass +from functools import lru_cache + +from Crypto.Cipher import AES +from sqlalchemy import text +from sqlalchemy.engine import Engine +from sqlalchemy.types import Text, TypeDecorator + + +_PREFIX = "enc:v1:" +_FALLBACK_SECRET = "douyu-login-py-dev-storage-key-change-me" + + +@dataclass(frozen=True) +class _KeyCandidate: + """可用于解密的候选密钥。""" + + name: str + secret: str + primary: bool = False + + +@lru_cache(maxsize=1) +def _key_candidates() -> tuple[_KeyCandidate, ...]: + """返回主加密密钥与历史兼容解密密钥。""" + app_key = os.getenv("APP_ENCRYPTION_KEY", "").strip() + jwt_key = os.getenv("JWT_SECRET_KEY", "").strip() + + if app_key: + primary = _KeyCandidate("APP_ENCRYPTION_KEY", app_key, True) + elif jwt_key: + primary = _KeyCandidate("JWT_SECRET_KEY", jwt_key, True) + else: + warnings.warn( + "未设置 APP_ENCRYPTION_KEY,敏感字段将使用开发兜底密钥加密。" + "生产环境请设置稳定且随机的 APP_ENCRYPTION_KEY。", + stacklevel=2, + ) + primary = _KeyCandidate("fallback", _FALLBACK_SECRET, True) + + candidates = [primary] + for name, secret in ( + ("APP_ENCRYPTION_KEY", app_key), + ("JWT_SECRET_KEY", jwt_key), + ("fallback", _FALLBACK_SECRET), + ): + if secret and all(item.secret != secret for item in candidates): + candidates.append(_KeyCandidate(name, secret, False)) + return tuple(candidates) + + +def _aes_key(secret: str) -> bytes: + """从配置密钥派生 AES-256 key。""" + return hashlib.sha256(secret.encode("utf-8")).digest() + + +def _b64e(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii") + + +def _b64d(data: str) -> bytes: + return base64.urlsafe_b64decode(data.encode("ascii")) + + +def is_encrypted(value: str | None) -> bool: + """判断值是否已经是当前加密格式。""" + return bool(value) and value.startswith(_PREFIX) + + +def encrypt_value(value: str | None) -> str | None: + """加密字符串;空值保持空值,避免影响空值筛选。""" + if value is None or value == "": + return value + if is_encrypted(value): + return value + + candidate = _key_candidates()[0] + nonce = os.urandom(12) + cipher = AES.new(_aes_key(candidate.secret), AES.MODE_GCM, nonce=nonce) + ciphertext, tag = cipher.encrypt_and_digest(value.encode("utf-8")) + return _PREFIX + _b64e(nonce + tag + ciphertext) + + +def _decrypt_with_candidate(value: str, candidate: _KeyCandidate) -> str: + raw = _b64d(value[len(_PREFIX):]) + if len(raw) < 28: + raise ValueError("密文字段长度无效") + nonce = raw[:12] + tag = raw[12:28] + ciphertext = raw[28:] + cipher = AES.new(_aes_key(candidate.secret), AES.MODE_GCM, nonce=nonce) + return cipher.decrypt_and_verify(ciphertext, tag).decode("utf-8") + + +def decrypt_value(value: str | None) -> str | None: + """解密字符串;旧明文会原样返回。""" + if value is None or value == "" or not is_encrypted(value): + return value + + last_error: Exception | None = None + for candidate in _key_candidates(): + try: + return _decrypt_with_candidate(value, candidate) + except Exception as exc: + last_error = exc + raise ValueError("敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确") from last_error + + +def decrypt_value_with_key_name(value: str) -> tuple[str, str]: + """解密并返回实际使用的密钥名称,用于历史密钥迁移。""" + if not is_encrypted(value): + return value, "plain" + last_error: Exception | None = None + for candidate in _key_candidates(): + try: + return _decrypt_with_candidate(value, candidate), candidate.name + except Exception as exc: + last_error = exc + raise ValueError("敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确") from last_error + + +class EncryptedText(TypeDecorator): + """SQLAlchemy 字段类型:数据库密文,Python 属性明文。""" + + impl = Text + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is None: + return None + return encrypt_value(str(value)) + + def process_result_value(self, value, dialect): + return decrypt_value(value) + + +_SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = ( + ("accounts", "password"), + ("accounts", "email"), + ("accounts", "email_password"), + ("login_tasks", "cookie"), + ("proxy_config", "api_url"), + ("proxy_config", "http"), + ("proxy_config", "https"), + ("proxy_config", "whitelist_uid"), + ("proxy_config", "whitelist_ukey"), +) + + +def _table_exists(engine: Engine, table_name: str) -> bool: + query = text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name") + if not engine.url.get_backend_name().startswith("sqlite"): + return True + with engine.connect() as conn: + return conn.execute(query, {"name": table_name}).first() is not None + + +def encrypt_existing_sensitive_data(engine: Engine) -> int: + """把历史明文敏感字段迁移成密文;已加密但非主密钥的值会重加密。""" + primary_name = _key_candidates()[0].name + changed = 0 + + with engine.begin() as conn: + for table_name, column_name in _SENSITIVE_COLUMNS: + if not _table_exists(engine, table_name): + continue + rows = conn.execute( + text(f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL") + ).mappings().all() + for row in rows: + raw_value = row[column_name] + if raw_value is None or raw_value == "": + continue + plain_value, key_name = decrypt_value_with_key_name(str(raw_value)) + if is_encrypted(str(raw_value)) and key_name == primary_name: + continue + encrypted = encrypt_value(plain_value) + conn.execute( + text(f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"), + {"value": encrypted, "id": row["id"]}, + ) + changed += 1 + return changed diff --git a/web/backend/database.py b/web/backend/database.py index 2c86125..8299d76 100644 --- a/web/backend/database.py +++ b/web/backend/database.py @@ -35,6 +35,7 @@ def init_db(): """执行数据库迁移 + 写入初始数据。""" run_migrations() _seed() + _encrypt_existing_sensitive_data() def run_migrations(): @@ -70,3 +71,13 @@ def _seed(): db.commit() finally: db.close() + + +def _encrypt_existing_sensitive_data(): + """启动时把历史明文敏感数据迁移为密文。""" + from loguru import logger + from .crypto_storage import encrypt_existing_sensitive_data + + changed = encrypt_existing_sensitive_data(engine) + if changed: + logger.info(f"已加密历史敏感字段: {changed} 个值") diff --git a/web/backend/migrations/versions/20260623_0001_initial_web_schema.py b/web/backend/migrations/versions/20260623_0001_initial_web_schema.py index 5b4bc97..debe4f4 100644 --- a/web/backend/migrations/versions/20260623_0001_initial_web_schema.py +++ b/web/backend/migrations/versions/20260623_0001_initial_web_schema.py @@ -70,9 +70,9 @@ def upgrade() -> None: "accounts", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("username", sa.String(length=128), nullable=False), - sa.Column("password", sa.String(length=256), nullable=False), - sa.Column("email", sa.String(length=128), nullable=False), - sa.Column("email_password", sa.String(length=256), nullable=False), + sa.Column("password", sa.Text(), nullable=False), + sa.Column("email", sa.Text(), nullable=False), + sa.Column("email_password", sa.Text(), nullable=False), sa.Column("email_imap_server", sa.String(length=128), nullable=True), sa.Column("email_imap_port", sa.Integer(), nullable=True), sa.Column("email_imap_ssl", sa.Boolean(), nullable=True), @@ -102,12 +102,12 @@ def upgrade() -> None: "proxy_config", sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("enabled", sa.Boolean(), nullable=True), - sa.Column("api_url", sa.String(length=512), nullable=True), - sa.Column("http", sa.String(length=256), nullable=True), - sa.Column("https", sa.String(length=256), nullable=True), + sa.Column("api_url", sa.Text(), nullable=True), + sa.Column("http", sa.Text(), nullable=True), + sa.Column("https", sa.Text(), nullable=True), sa.Column("whitelist_enabled", sa.Boolean(), nullable=True), - sa.Column("whitelist_uid", sa.String(length=64), nullable=True), - sa.Column("whitelist_ukey", sa.String(length=128), nullable=True), + sa.Column("whitelist_uid", sa.Text(), nullable=True), + sa.Column("whitelist_ukey", sa.Text(), nullable=True), sa.PrimaryKeyConstraint("id"), ) diff --git a/web/backend/migrations/versions/20260624_0002_sensitive_columns_text.py b/web/backend/migrations/versions/20260624_0002_sensitive_columns_text.py new file mode 100644 index 0000000..f8a3248 --- /dev/null +++ b/web/backend/migrations/versions/20260624_0002_sensitive_columns_text.py @@ -0,0 +1,51 @@ +"""放宽敏感字段列类型,便于存储加密密文 + +Revision ID: 20260624_0002 +Revises: 20260623_0001 +Create Date: 2026-06-24 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260624_0002" +down_revision: Union[str, None] = "20260623_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("accounts") as batch: + batch.alter_column("password", existing_type=sa.String(length=256), type_=sa.Text()) + batch.alter_column("email", existing_type=sa.String(length=128), type_=sa.Text()) + batch.alter_column("email_password", existing_type=sa.String(length=256), type_=sa.Text()) + + with op.batch_alter_table("proxy_config") as batch: + batch.alter_column("api_url", existing_type=sa.String(length=512), type_=sa.Text()) + batch.alter_column("http", existing_type=sa.String(length=256), type_=sa.Text()) + batch.alter_column("https", existing_type=sa.String(length=256), type_=sa.Text()) + batch.alter_column("whitelist_uid", existing_type=sa.String(length=64), type_=sa.Text()) + batch.alter_column("whitelist_ukey", existing_type=sa.String(length=128), type_=sa.Text()) + + with op.batch_alter_table("login_tasks") as batch: + batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text()) + + +def downgrade() -> None: + with op.batch_alter_table("login_tasks") as batch: + batch.alter_column("cookie", existing_type=sa.Text(), type_=sa.Text()) + + with op.batch_alter_table("proxy_config") as batch: + batch.alter_column("whitelist_ukey", existing_type=sa.Text(), type_=sa.String(length=128)) + batch.alter_column("whitelist_uid", existing_type=sa.Text(), type_=sa.String(length=64)) + batch.alter_column("https", existing_type=sa.Text(), type_=sa.String(length=256)) + batch.alter_column("http", existing_type=sa.Text(), type_=sa.String(length=256)) + batch.alter_column("api_url", existing_type=sa.Text(), type_=sa.String(length=512)) + + with op.batch_alter_table("accounts") as batch: + batch.alter_column("email_password", existing_type=sa.Text(), type_=sa.String(length=256)) + batch.alter_column("email", existing_type=sa.Text(), type_=sa.String(length=128)) + batch.alter_column("password", existing_type=sa.Text(), type_=sa.String(length=256)) diff --git a/web/backend/models.py b/web/backend/models.py index fa30ab5..9f03aac 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -6,6 +6,7 @@ from sqlalchemy import ( ) from sqlalchemy.orm import relationship from .database import Base +from .crypto_storage import EncryptedText def _utcnow(): @@ -37,9 +38,9 @@ class Account(Base): id = Column(Integer, primary_key=True, autoincrement=True) username = Column(String(128), nullable=False) - password = Column(String(256), nullable=False) - email = Column(String(128), nullable=False) - email_password = Column(String(256), nullable=False) + password = Column(EncryptedText(), nullable=False) + email = Column(EncryptedText(), nullable=False) + email_password = Column(EncryptedText(), nullable=False) email_imap_server = Column(String(128), default="") email_imap_port = Column(Integer, default=993) email_imap_ssl = Column(Boolean, default=True) @@ -60,7 +61,7 @@ class LoginTask(Base): batch_id = Column(String(64), nullable=False, index=True) # 批次ID account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False) status = Column(String(32), default="pending") # pending / running / success / failed / error - cookie = Column(Text, default="") + cookie = Column(EncryptedText(), default="") message = Column(String(512), default="") created_by = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime, default=_utcnow) @@ -75,13 +76,13 @@ class ProxyConfig(Base): id = Column(Integer, primary_key=True, autoincrement=True) enabled = Column(Boolean, default=False) - api_url = Column(String(512), default="") - http = Column(String(256), default="") - https = Column(String(256), default="") + api_url = Column(EncryptedText(), default="") + http = Column(EncryptedText(), default="") + https = Column(EncryptedText(), default="") # 白名单 whitelist_enabled = Column(Boolean, default=False) - whitelist_uid = Column(String(64), default="") - whitelist_ukey = Column(String(128), default="") + whitelist_uid = Column(EncryptedText(), default="") + whitelist_ukey = Column(EncryptedText(), default="") class AuditLog(Base): diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index f8ceb90..5209b0c 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -197,8 +197,8 @@ class LoginBatchRunner: if result.success: task.status = "success" task.cookie = result.cookie - task.message = "登录成功" - self._push_log("success", f"[{current}] {acc_info['username']} 登录成功") + task.message = result.message or "登录成功" + self._push_log("success", f"[{current}] {acc_info['username']} {task.message}") else: task.status = "failed" task.message = result.message