Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,13 @@ without leaking yt-dlp options. yt-dlp runs in isolated worker processes, while
downloaded assets live in expiring leases and are removed when `MediaAsset` is
closed. Persistence requires an explicit `export_to()` call.

Browser credentials are handled by `CookieService`. A task receives a private,
short-lived Cookie lease that is deleted by default. Opt-in retained credentials
are AEAD-encrypted and their master key is stored in the operating-system
keyring; plaintext Cookie files are never retained.
Browser credentials are handled by `AuthManager`. It validates encrypted stored
cookies and can refresh them from Chrome, Edge, Brave, Arc, Chromium, Firefox,
or Safari. Authentication failures are refreshed and retried at most once. Each
task receives a private `0600` Cookie lease that is deleted immediately after
use; retained credentials are AEAD-encrypted with a key stored in the operating
system keyring. Run `noteforge auth --help` for browser, JSON, stdin, and
interactive login options.

---

Expand Down
25 changes: 21 additions & 4 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,27 @@
调用方不会接触其参数或下载路径。媒体默认保存在有 TTL 的临时租约中,退出
`MediaAsset` 上下文后立即删除;只有显式调用 `export_to()` 才会持久化。

浏览器身份由独立 `CookieService` 管理。每个任务只获得权限为 `0600` 的短期
Cookie 租约,任务结束默认销毁。用户显式选择 `CookiePersistence.RETAIN` 时,
目标平台 Cookie 使用 AEAD 加密保存,主密钥进入系统 Keyring;不会持久化明文
`cookies.txt`。`config.yaml`、`.noteforge/` 与 `.cache/` 已被 Git 忽略。
浏览器身份由独立 `AuthManager` 管理。它会验证加密 Store 中的 Cookie,失效时
从 Chrome、Edge、Brave、Arc、Chromium、Firefox 或 Safari 重新导入,并让认证
失败的业务请求最多自动重试一次。每个任务只获得权限为 `0600` 的短期 Cookie
租约,任务结束立即销毁;持久凭据使用 AEAD 加密,主密钥进入系统 Keyring。
不会持久化明文 `cookies.txt`,也不会在日志和错误中输出 Cookie。

```bash
# 自动发现浏览器,也可用 --browser chrome 指定来源
noteforge auth login --platform bilibili

# 支持扩展 JSON、标准输入和交互式网页登录
noteforge auth login --platform bilibili ~/Downloads/cookies.json
noteforge auth login --platform bilibili --raw-stdin
noteforge auth login --platform youtube --qr

noteforge auth status
noteforge auth logout --platform bilibili
```

交互登录首次使用前需要运行 `playwright install chromium`。命令行 `--raw` 可能被
Shell history 记录,推荐使用 `--raw-stdin`。

字幕 fallback 顺序为人工字幕、自动字幕、可选的音频转录器;媒体层目前可解析
VTT、SRT、ASS 和 JSON3,并为 Whisper 实现保留了 `AudioTranscriber` 接口。
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [
"cryptography>=45,<48",
"httpx>=0.28,<1",
"keyring>=25,<27",
"playwright>=1.48,<2",
"rich>=13,<15",
"typer>=0.12,<1",
"yt-dlp[curl-cffi]>=2026.7.4",
Expand Down
39 changes: 39 additions & 0 deletions src/noteforge/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""NoteForge 统一认证公共 API。"""

from noteforge.auth.errors import (
AuthError,
AuthRequiredError,
CookieExpiredError,
CookieImportError,
CookieValidationError,
InteractiveLoginError,
)
from noteforge.auth.manager import AuthManager
from noteforge.auth.models import AuthPlatform, AuthResult, AuthStatus, CookieSource
from noteforge.auth.providers import (
BrowserCookieProvider,
JsonCookieProvider,
PlaywrightCookieProvider,
RawCookieProvider,
)
from noteforge.auth.store import CookieStore, EncryptedCookieStore

__all__ = [
"AuthError",
"AuthManager",
"AuthPlatform",
"AuthRequiredError",
"AuthResult",
"AuthStatus",
"BrowserCookieProvider",
"CookieExpiredError",
"CookieImportError",
"CookieSource",
"CookieStore",
"CookieValidationError",
"EncryptedCookieStore",
"InteractiveLoginError",
"JsonCookieProvider",
"PlaywrightCookieProvider",
"RawCookieProvider",
]
27 changes: 27 additions & 0 deletions src/noteforge/auth/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""认证生命周期中可供业务层分类处理的异常。"""

from noteforge.exceptions.base import NoteForgeError


class AuthError(NoteForgeError):
"""认证错误基类。"""


class AuthRequiredError(AuthError):
"""当前操作需要有效登录态。"""


class CookieExpiredError(AuthRequiredError):
"""Cookie 已存在但远程平台确认登录态失效。"""


class CookieImportError(AuthError):
"""无法从指定来源导入 Cookie。"""


class CookieValidationError(AuthError):
"""Cookie 验证请求失败或返回了无效数据。"""


class InteractiveLoginError(AuthError):
"""交互式登录失败、取消或超时。"""
188 changes: 188 additions & 0 deletions src/noteforge/auth/manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""统一编排 Cookie 获取、验证、刷新、持久化与短期租约。"""

from __future__ import annotations

import http.cookiejar
import threading
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING

from noteforge.auth.errors import (
AuthRequiredError,
CookieExpiredError,
CookieImportError,
)
from noteforge.auth.models import AuthPlatform, AuthResult, AuthStatus, CookieSource
from noteforge.auth.providers import (
BrowserCookieProvider,
JsonCookieProvider,
PlaywrightCookieProvider,
RawCookieProvider,
)
from noteforge.auth.store import CookieStore, EncryptedCookieStore
from noteforge.auth.validator import CookieValidator

if TYPE_CHECKING:
from noteforge.media.cookies.service import CookieLease, CookieService


class AuthManager:
"""业务层唯一允许使用的认证生命周期入口。"""

def __init__(
self,
store: CookieStore | None = None,
*,
validator: CookieValidator | None = None,
cookie_service: CookieService | None = None,
browser_provider_factory: Callable[[str | None], BrowserCookieProvider]
| None = None,
) -> None:
# 延迟导入可避免 media 公共包初始化时反向加载 AuthManager。
if cookie_service is None:
from noteforge.media.cookies.service import CookieService

cookie_service = CookieService()
self.store = store or EncryptedCookieStore()
self.validator = validator or CookieValidator()
self.cookie_service = cookie_service
self._browser_provider_factory = browser_provider_factory or (
lambda browser: BrowserCookieProvider(browser)
)
self._locks = {platform: threading.RLock() for platform in AuthPlatform}

def get_cookie(
self, platform: AuthPlatform, *, browser: str | None = None
) -> CookieLease:
"""读取并验证 Store;无有效凭据时自动从浏览器刷新。"""

platform = AuthPlatform(platform)
with self._locks[platform]:
stored = self.store.load(platform)
if stored is not None:
result = self.validator.validate(platform, stored)
if result.status is AuthStatus.AUTHENTICATED:
return self.cookie_service.lease(platform.value, stored)
return self.refresh(platform, browser=browser)

def refresh(
self, platform: AuthPlatform, *, browser: str | None = None
) -> CookieLease:
"""优先从本机浏览器重新导入、验证并持久化 Cookie。"""

platform = AuthPlatform(platform)
with self._locks[platform]:
provider = self._browser_provider_factory(browser)
try:
cookies = provider.load(platform)
except CookieImportError as error:
if self.store.exists(platform):
raise CookieExpiredError(
f"{platform.value} Cookie 已过期,自动刷新未成功。"
) from error
raise AuthRequiredError(
f"未找到有效的 {platform.value} 浏览器登录态。"
) from error
self._validate_required(platform, cookies)
source = provider.source or CookieSource("browser", browser)
self.store.save(platform, cookies, source)
return self.cookie_service.lease(platform.value, cookies)

def is_authenticated(self, platform: AuthPlatform) -> bool:
"""返回当前持久凭据是否通过真实登录态验证。"""

return self.status(platform).status is AuthStatus.AUTHENTICATED

def status(self, platform: AuthPlatform) -> AuthResult:
"""返回不包含 Cookie 明文的认证状态。"""

platform = AuthPlatform(platform)
cookies = self.store.load(platform)
if cookies is None:
return AuthResult(platform, AuthStatus.NO_COOKIE)
result = self.validator.validate(platform, cookies)
metadata = (
self.store.metadata(platform)
if isinstance(self.store, EncryptedCookieStore)
else {}
)
refreshed = metadata.get("refreshed_at")
try:
refreshed_at = (
datetime.fromisoformat(refreshed)
if isinstance(refreshed, str)
else None
)
except ValueError:
refreshed_at = None
return AuthResult(
platform,
result.status,
str(metadata.get("source")) if metadata.get("source") else None,
str(metadata.get("browser")) if metadata.get("browser") else None,
refreshed_at,
)

def login_from_browser(
self, platform: AuthPlatform, *, browser: str | None = None
) -> AuthResult:
"""显式从浏览器导入,不复用 Store 中的旧 Cookie。"""

platform = AuthPlatform(platform)
provider = self._browser_provider_factory(browser)
cookies = provider.load(platform)
result = self._validate_required(platform, cookies)
source = provider.source or CookieSource("browser", browser)
self.store.save(platform, cookies, source)
return AuthResult(
platform,
result.status,
source.kind,
source.browser,
result.refreshed_at,
)

def login_from_json(self, platform: AuthPlatform, path: Path) -> AuthResult:
"""从扩展导出的 JSON 导入并验证登录态。"""

return self._login_provider(platform, JsonCookieProvider(path), "json")

def login_from_raw(self, platform: AuthPlatform, raw_cookie: str) -> AuthResult:
"""从 Cookie Header 文本导入并验证登录态。"""

return self._login_provider(platform, RawCookieProvider(raw_cookie), "raw")

def login_interactively(
self, platform: AuthPlatform, *, timeout: int = 180
) -> AuthResult:
"""启动可见浏览器并在真实验证成功后保存 Cookie。"""

return self._login_provider(
platform, PlaywrightCookieProvider(timeout), "playwright"
)

def logout(self, platform: AuthPlatform) -> None:
"""清除 NoteForge 保存的指定平台凭据。"""

self.store.clear(AuthPlatform(platform))

def _login_provider(self, platform, provider, source: str) -> AuthResult:
platform = AuthPlatform(platform)
cookies = provider.load(platform)
result = self._validate_required(platform, cookies)
self.store.save(platform, cookies, CookieSource(source))
return AuthResult(
platform, result.status, source, refreshed_at=result.refreshed_at
)

def _validate_required(
self, platform: AuthPlatform, cookies: http.cookiejar.CookieJar
) -> AuthResult:
result = self.validator.validate(platform, cookies)
if result.status is AuthStatus.AUTHENTICATED:
return result
if result.status is AuthStatus.COOKIE_EXPIRED:
raise CookieExpiredError(f"{platform.value} Cookie 已失效。")
raise CookieImportError(f"导入的 {platform.value} Cookie 缺少必要登录字段。")
41 changes: 41 additions & 0 deletions src/noteforge/auth/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""认证领域模型;所有公开结果均不得包含 Cookie 明文。"""

from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum


class AuthPlatform(StrEnum):
"""NoteForge 支持认证生命周期的平台。"""

BILIBILI = "bilibili"
YOUTUBE = "youtube"


class AuthStatus(StrEnum):
"""凭据验证后的稳定状态。"""

AUTHENTICATED = "authenticated"
NO_COOKIE = "no_cookie"
MISSING_REQUIRED_FIELDS = "missing_required_fields"
COOKIE_EXPIRED = "cookie_expired"
IMPORT_FAILED = "import_failed"


@dataclass(frozen=True, slots=True)
class CookieSource:
"""Cookie 来源的非敏感描述。"""

kind: str
browser: str | None = None


@dataclass(frozen=True, slots=True)
class AuthResult:
"""可安全展示和记录的认证结果。"""

platform: AuthPlatform
status: AuthStatus
source: str | None = None
browser: str | None = None
refreshed_at: datetime | None = None
15 changes: 15 additions & 0 deletions src/noteforge/auth/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""NoteForge 内置 Cookie Provider。"""

from noteforge.auth.providers.base import CookieProvider
from noteforge.auth.providers.browser import BrowserCookieProvider
from noteforge.auth.providers.json_file import JsonCookieProvider
from noteforge.auth.providers.playwright import PlaywrightCookieProvider
from noteforge.auth.providers.raw import RawCookieProvider

__all__ = [
"BrowserCookieProvider",
"CookieProvider",
"JsonCookieProvider",
"PlaywrightCookieProvider",
"RawCookieProvider",
]
Loading